Răsfoiți Sursa

Release 4.3.0 (#327)

Initial release of Firestore at 0.8.0
Bump FirebaseCommunity to 0.1.3
Gil 8 ani în urmă
părinte
comite
bde743ed25
100 a modificat fișierele cu 23958 adăugiri și 1 ștergeri
  1. 1 0
      Firebase/Core/FIRLogger.m
  2. 1 0
      Firebase/Core/Private/FIRLogger.h
  3. 1 1
      FirebaseCommunity.podspec
  4. 1700 0
      Firestore/Example/Firestore.xcodeproj/project.pbxproj
  5. 111 0
      Firestore/Example/Firestore.xcodeproj/xcshareddata/xcschemes/AllTests.xcscheme
  6. 113 0
      Firestore/Example/Firestore.xcodeproj/xcshareddata/xcschemes/Firestore-Example.xcscheme
  7. 71 0
      Firestore/Example/Firestore.xcodeproj/xcshareddata/xcschemes/Firestore_IntegrationTests.xcscheme
  8. 71 0
      Firestore/Example/Firestore.xcodeproj/xcshareddata/xcschemes/Firestore_Tests.xcscheme
  9. 91 0
      Firestore/Example/Firestore.xcodeproj/xcshareddata/xcschemes/SwiftBuildTest.xcscheme
  10. 27 0
      Firestore/Example/Firestore/Base.lproj/LaunchScreen.storyboard
  11. 27 0
      Firestore/Example/Firestore/Base.lproj/Main.storyboard
  12. 23 0
      Firestore/Example/Firestore/FIRAppDelegate.h
  13. 57 0
      Firestore/Example/Firestore/FIRAppDelegate.m
  14. 21 0
      Firestore/Example/Firestore/FIRViewController.h
  15. 35 0
      Firestore/Example/Firestore/FIRViewController.m
  16. 49 0
      Firestore/Example/Firestore/Firestore-Info.plist
  17. 93 0
      Firestore/Example/Firestore/Images.xcassets/AppIcon.appiconset/Contents.json
  18. 2 0
      Firestore/Example/Firestore/en.lproj/InfoPlist.strings
  19. 24 0
      Firestore/Example/Firestore/main.m
  20. 22 0
      Firestore/Example/Podfile
  21. 284 0
      Firestore/Example/SwiftBuildTest/main.swift
  22. 67 0
      Firestore/Example/Tests/API/FIRGeoPointTests.m
  23. 59 0
      Firestore/Example/Tests/Core/FSTDatabaseInfoTests.m
  24. 163 0
      Firestore/Example/Tests/Core/FSTEventManagerTests.m
  25. 487 0
      Firestore/Example/Tests/Core/FSTQueryListenerTests.m
  26. 577 0
      Firestore/Example/Tests/Core/FSTQueryTests.m
  27. 32 0
      Firestore/Example/Tests/Core/FSTSyncEngine+Testing.h
  28. 94 0
      Firestore/Example/Tests/Core/FSTTargetIDGeneratorTests.m
  29. 88 0
      Firestore/Example/Tests/Core/FSTTimestampTests.m
  30. 141 0
      Firestore/Example/Tests/Core/FSTViewSnapshotTest.m
  31. 618 0
      Firestore/Example/Tests/Core/FSTViewTests.m
  32. 195 0
      Firestore/Example/Tests/Integration/API/FIRCursorTests.m
  33. 741 0
      Firestore/Example/Tests/Integration/API/FIRDatabaseTests.m
  34. 223 0
      Firestore/Example/Tests/Integration/API/FIRFieldsTests.m
  35. 129 0
      Firestore/Example/Tests/Integration/API/FIRListenerRegistrationTests.m
  36. 197 0
      Firestore/Example/Tests/Integration/API/FIRQueryTests.m
  37. 183 0
      Firestore/Example/Tests/Integration/API/FIRServerTimestampTests.m
  38. 79 0
      Firestore/Example/Tests/Integration/API/FIRTypeTests.m
  39. 560 0
      Firestore/Example/Tests/Integration/API/FIRValidationTests.m
  40. 313 0
      Firestore/Example/Tests/Integration/API/FIRWriteBatchTests.m
  41. 0 0
      Firestore/Example/Tests/Integration/CAcert.pem
  42. 239 0
      Firestore/Example/Tests/Integration/FSTDatastoreTests.m
  43. 129 0
      Firestore/Example/Tests/Integration/FSTSmokeTests.m
  44. 541 0
      Firestore/Example/Tests/Integration/FSTTransactionTests.m
  45. 111 0
      Firestore/Example/Tests/Local/FSTEagerGarbageCollectorTests.m
  46. 361 0
      Firestore/Example/Tests/Local/FSTLevelDBKeyTests.mm
  47. 45 0
      Firestore/Example/Tests/Local/FSTLevelDBLocalStoreTests.m
  48. 158 0
      Firestore/Example/Tests/Local/FSTLevelDBMutationQueueTests.mm
  49. 54 0
      Firestore/Example/Tests/Local/FSTLevelDBQueryCacheTests.m
  50. 78 0
      Firestore/Example/Tests/Local/FSTLevelDBRemoteDocumentCacheTests.mm
  51. 181 0
      Firestore/Example/Tests/Local/FSTLocalSerializerTests.m
  52. 38 0
      Firestore/Example/Tests/Local/FSTLocalStoreTests.h
  53. 795 0
      Firestore/Example/Tests/Local/FSTLocalStoreTests.m
  54. 44 0
      Firestore/Example/Tests/Local/FSTMemoryLocalStoreTests.m
  55. 42 0
      Firestore/Example/Tests/Local/FSTMemoryMutationQueueTests.m
  56. 54 0
      Firestore/Example/Tests/Local/FSTMemoryQueryCacheTests.m
  57. 49 0
      Firestore/Example/Tests/Local/FSTMemoryRemoteDocumentCacheTests.m
  58. 38 0
      Firestore/Example/Tests/Local/FSTMutationQueueTests.h
  59. 511 0
      Firestore/Example/Tests/Local/FSTMutationQueueTests.m
  60. 40 0
      Firestore/Example/Tests/Local/FSTPersistenceTestHelpers.h
  61. 72 0
      Firestore/Example/Tests/Local/FSTPersistenceTestHelpers.m
  62. 47 0
      Firestore/Example/Tests/Local/FSTQueryCacheTests.h
  63. 375 0
      Firestore/Example/Tests/Local/FSTQueryCacheTests.m
  64. 84 0
      Firestore/Example/Tests/Local/FSTReferenceSetTests.m
  65. 39 0
      Firestore/Example/Tests/Local/FSTRemoteDocumentCacheTests.h
  66. 151 0
      Firestore/Example/Tests/Local/FSTRemoteDocumentCacheTests.m
  67. 113 0
      Firestore/Example/Tests/Local/FSTRemoteDocumentChangeBufferTests.m
  68. 121 0
      Firestore/Example/Tests/Local/FSTWriteGroupTests.mm
  69. 45 0
      Firestore/Example/Tests/Model/FSTDatabaseIDTests.m
  70. 60 0
      Firestore/Example/Tests/Model/FSTDocumentKeyTests.m
  71. 142 0
      Firestore/Example/Tests/Model/FSTDocumentSetTests.m
  72. 101 0
      Firestore/Example/Tests/Model/FSTDocumentTests.m
  73. 576 0
      Firestore/Example/Tests/Model/FSTFieldValueTests.m
  74. 216 0
      Firestore/Example/Tests/Model/FSTMutationTests.m
  75. 196 0
      Firestore/Example/Tests/Model/FSTPathTests.m
  76. 58 0
      Firestore/Example/Tests/Remote/FSTDatastoreTests.m
  77. 556 0
      Firestore/Example/Tests/Remote/FSTRemoteEventTests.m
  78. 794 0
      Firestore/Example/Tests/Remote/FSTSerializerBetaTests.m
  79. 139 0
      Firestore/Example/Tests/Remote/FSTStreamTests.m
  80. 40 0
      Firestore/Example/Tests/Remote/FSTWatchChange+Testing.h
  81. 54 0
      Firestore/Example/Tests/Remote/FSTWatchChange+Testing.m
  82. 66 0
      Firestore/Example/Tests/Remote/FSTWatchChangeTests.m
  83. 43 0
      Firestore/Example/Tests/SpecTests/FSTLevelDBSpecTests.m
  84. 42 0
      Firestore/Example/Tests/SpecTests/FSTMemorySpecTests.m
  85. 68 0
      Firestore/Example/Tests/SpecTests/FSTMockDatastore.h
  86. 344 0
      Firestore/Example/Tests/SpecTests/FSTMockDatastore.m
  87. 46 0
      Firestore/Example/Tests/SpecTests/FSTSpecTests.h
  88. 642 0
      Firestore/Example/Tests/SpecTests/FSTSpecTests.m
  89. 248 0
      Firestore/Example/Tests/SpecTests/FSTSyncEngineTestDriver.h
  90. 291 0
      Firestore/Example/Tests/SpecTests/FSTSyncEngineTestDriver.m
  91. 3 0
      Firestore/Example/Tests/SpecTests/json/README.md
  92. 147 0
      Firestore/Example/Tests/SpecTests/json/collection_spec_test.json
  93. 738 0
      Firestore/Example/Tests/SpecTests/json/existence_filter_spec_test.json
  94. 1150 0
      Firestore/Example/Tests/SpecTests/json/limbo_spec_test.json
  95. 1626 0
      Firestore/Example/Tests/SpecTests/json/limit_spec_test.json
  96. 1524 0
      Firestore/Example/Tests/SpecTests/json/listen_spec_test.json
  97. 151 0
      Firestore/Example/Tests/SpecTests/json/offline_spec_test.json
  98. 155 0
      Firestore/Example/Tests/SpecTests/json/orderby_spec_test.json
  99. 858 0
      Firestore/Example/Tests/SpecTests/json/persistence_spec_test.json
  100. 559 0
      Firestore/Example/Tests/SpecTests/json/remote_store_spec_test.json

+ 1 - 0
Firebase/Core/FIRLogger.m

@@ -32,6 +32,7 @@ FIRLoggerService kFIRLoggerCore = @"[Firebase/Core]";
 FIRLoggerService kFIRLoggerCrash = @"[Firebase/Crash]";
 FIRLoggerService kFIRLoggerDatabase = @"[Firebase/Database]";
 FIRLoggerService kFIRLoggerDynamicLinks = @"[Firebase/DynamicLinks]";
+FIRLoggerService kFIRLoggerFirestore = @"[Firebase/Firestore]";
 FIRLoggerService kFIRLoggerInstanceID = @"[Firebase/InstanceID]";
 FIRLoggerService kFIRLoggerInvites = @"[Firebase/Invites]";
 FIRLoggerService kFIRLoggerMessaging = @"[Firebase/Messaging]";

+ 1 - 0
Firebase/Core/Private/FIRLogger.h

@@ -33,6 +33,7 @@ extern FIRLoggerService kFIRLoggerCore;
 extern FIRLoggerService kFIRLoggerCrash;
 extern FIRLoggerService kFIRLoggerDatabase;
 extern FIRLoggerService kFIRLoggerDynamicLinks;
+extern FIRLoggerService kFIRLoggerFirestore;
 extern FIRLoggerService kFIRLoggerInstanceID;
 extern FIRLoggerService kFIRLoggerInvites;
 extern FIRLoggerService kFIRLoggerMessaging;

+ 1 - 1
FirebaseCommunity.podspec

@@ -1,6 +1,6 @@
 Pod::Spec.new do |s|
   s.name             = 'FirebaseCommunity'
-  s.version          = '0.1.2'
+  s.version          = '0.1.3'
   s.summary          = 'Firebase Open Source Libraries for iOS.'
 
   s.description      = <<-DESC

+ 1700 - 0
Firestore/Example/Firestore.xcodeproj/project.pbxproj

@@ -0,0 +1,1700 @@
+// !$*UTF8*$!
+{
+	archiveVersion = 1;
+	classes = {
+	};
+	objectVersion = 46;
+	objects = {
+
+/* Begin PBXAggregateTarget section */
+		DE29E7F51F2174B000909613 /* AllTests */ = {
+			isa = PBXAggregateTarget;
+			buildConfigurationList = DE29E7F81F2174B000909613 /* Build configuration list for PBXAggregateTarget "AllTests" */;
+			buildPhases = (
+			);
+			dependencies = (
+				DE0761FA1F2FEE7E003233AF /* PBXTargetDependency */,
+				DE29E7FA1F2174DD00909613 /* PBXTargetDependency */,
+				DE29E7FC1F2174DD00909613 /* PBXTargetDependency */,
+			);
+			name = AllTests;
+			productName = AllTests;
+		};
+/* End PBXAggregateTarget section */
+
+/* Begin PBXBuildFile section */
+		3B843E4C1F3A182900548890 /* remote_store_spec_test.json in Resources */ = {isa = PBXBuildFile; fileRef = 3B843E4A1F3930A400548890 /* remote_store_spec_test.json */; };
+		54DA12A61F315EE100DD57A1 /* collection_spec_test.json in Resources */ = {isa = PBXBuildFile; fileRef = 54DA129C1F315EE100DD57A1 /* collection_spec_test.json */; };
+		54DA12A71F315EE100DD57A1 /* existence_filter_spec_test.json in Resources */ = {isa = PBXBuildFile; fileRef = 54DA129D1F315EE100DD57A1 /* existence_filter_spec_test.json */; };
+		54DA12A81F315EE100DD57A1 /* limbo_spec_test.json in Resources */ = {isa = PBXBuildFile; fileRef = 54DA129E1F315EE100DD57A1 /* limbo_spec_test.json */; };
+		54DA12A91F315EE100DD57A1 /* limit_spec_test.json in Resources */ = {isa = PBXBuildFile; fileRef = 54DA129F1F315EE100DD57A1 /* limit_spec_test.json */; };
+		54DA12AA1F315EE100DD57A1 /* listen_spec_test.json in Resources */ = {isa = PBXBuildFile; fileRef = 54DA12A01F315EE100DD57A1 /* listen_spec_test.json */; };
+		54DA12AB1F315EE100DD57A1 /* offline_spec_test.json in Resources */ = {isa = PBXBuildFile; fileRef = 54DA12A11F315EE100DD57A1 /* offline_spec_test.json */; };
+		54DA12AC1F315EE100DD57A1 /* orderby_spec_test.json in Resources */ = {isa = PBXBuildFile; fileRef = 54DA12A21F315EE100DD57A1 /* orderby_spec_test.json */; };
+		54DA12AD1F315EE100DD57A1 /* persistence_spec_test.json in Resources */ = {isa = PBXBuildFile; fileRef = 54DA12A31F315EE100DD57A1 /* persistence_spec_test.json */; };
+		54DA12AE1F315EE100DD57A1 /* resume_token_spec_test.json in Resources */ = {isa = PBXBuildFile; fileRef = 54DA12A41F315EE100DD57A1 /* resume_token_spec_test.json */; };
+		54DA12AF1F315EE100DD57A1 /* write_spec_test.json in Resources */ = {isa = PBXBuildFile; fileRef = 54DA12A51F315EE100DD57A1 /* write_spec_test.json */; };
+		54DA12B11F315F3800DD57A1 /* FIRValidationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = 54DA12B01F315F3800DD57A1 /* FIRValidationTests.m */; };
+		54E928221F33952900C1953E /* FSTIntegrationTestCase.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E9281F1F33950B00C1953E /* FSTIntegrationTestCase.m */; };
+		54E928231F33952D00C1953E /* FSTIntegrationTestCase.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E9281F1F33950B00C1953E /* FSTIntegrationTestCase.m */; };
+		54E928241F33953300C1953E /* FSTEventAccumulator.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E9281D1F33950B00C1953E /* FSTEventAccumulator.m */; };
+		54E928251F33953400C1953E /* FSTEventAccumulator.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E9281D1F33950B00C1953E /* FSTEventAccumulator.m */; };
+		54E9282C1F339CAD00C1953E /* XCTestCase+Await.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E9282B1F339CAD00C1953E /* XCTestCase+Await.m */; };
+		54E9282D1F339CAD00C1953E /* XCTestCase+Await.m in Sources */ = {isa = PBXBuildFile; fileRef = 54E9282B1F339CAD00C1953E /* XCTestCase+Await.m */; };
+		6003F58E195388D20070C39A /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6003F58D195388D20070C39A /* Foundation.framework */; };
+		6003F590195388D20070C39A /* CoreGraphics.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6003F58F195388D20070C39A /* CoreGraphics.framework */; };
+		6003F592195388D20070C39A /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6003F591195388D20070C39A /* UIKit.framework */; };
+		6003F598195388D20070C39A /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 6003F596195388D20070C39A /* InfoPlist.strings */; };
+		6003F59A195388D20070C39A /* main.m in Sources */ = {isa = PBXBuildFile; fileRef = 6003F599195388D20070C39A /* main.m */; };
+		6003F59E195388D20070C39A /* FIRAppDelegate.m in Sources */ = {isa = PBXBuildFile; fileRef = 6003F59D195388D20070C39A /* FIRAppDelegate.m */; };
+		6003F5A7195388D20070C39A /* FIRViewController.m in Sources */ = {isa = PBXBuildFile; fileRef = 6003F5A6195388D20070C39A /* FIRViewController.m */; };
+		6003F5A9195388D20070C39A /* Images.xcassets in Resources */ = {isa = PBXBuildFile; fileRef = 6003F5A8195388D20070C39A /* Images.xcassets */; };
+		6003F5B0195388D20070C39A /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6003F5AF195388D20070C39A /* XCTest.framework */; };
+		6003F5B1195388D20070C39A /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6003F58D195388D20070C39A /* Foundation.framework */; };
+		6003F5B2195388D20070C39A /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6003F591195388D20070C39A /* UIKit.framework */; };
+		6003F5BA195388D20070C39A /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 6003F5B8195388D20070C39A /* InfoPlist.strings */; };
+		6ED54761B845349D43DB6B78 /* Pods_Firestore_Example.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 75A6FE51C1A02DF38F62FAAD /* Pods_Firestore_Example.framework */; };
+		71719F9F1E33DC2100824A3D /* LaunchScreen.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 71719F9D1E33DC2100824A3D /* LaunchScreen.storyboard */; };
+		873B8AEB1B1F5CCA007FD442 /* Main.storyboard in Resources */ = {isa = PBXBuildFile; fileRef = 873B8AEA1B1F5CCA007FD442 /* Main.storyboard */; };
+		AFE6114F0D4DAECBA7B7C089 /* Pods_Firestore_IntegrationTests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B2FA635DF5D116A67A7441CD /* Pods_Firestore_IntegrationTests.framework */; };
+		C4E749275AD0FBDF9F4716A8 /* Pods_SwiftBuildTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 32AD40BF6B0E849B07FFD05E /* Pods_SwiftBuildTest.framework */; };
+		DE03B2C91F2149D600A30B9C /* FSTTransactionTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1C61F0D48AC0013853F /* FSTTransactionTests.m */; };
+		DE03B2D41F2149D600A30B9C /* XCTest.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6003F5AF195388D20070C39A /* XCTest.framework */; };
+		DE03B2D51F2149D600A30B9C /* UIKit.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6003F591195388D20070C39A /* UIKit.framework */; };
+		DE03B2D61F2149D600A30B9C /* Foundation.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 6003F58D195388D20070C39A /* Foundation.framework */; };
+		DE03B2D71F2149D600A30B9C /* Pods_Firestore_Tests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 69F6A10DBD6187489481CD76 /* Pods_Firestore_Tests.framework */; };
+		DE03B2DD1F2149D600A30B9C /* InfoPlist.strings in Resources */ = {isa = PBXBuildFile; fileRef = 6003F5B8195388D20070C39A /* InfoPlist.strings */; };
+		DE03B2EC1F214BA200A30B9C /* FSTDatastoreTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1C41F0D48AC0013853F /* FSTDatastoreTests.m */; };
+		DE03B2ED1F214BA200A30B9C /* FSTSmokeTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1C51F0D48AC0013853F /* FSTSmokeTests.m */; };
+		DE03B2EE1F214BAA00A30B9C /* FIRWriteBatchTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DEFE0F471F1F960A0071599A /* FIRWriteBatchTests.m */; };
+		DE03B2EF1F214BAA00A30B9C /* FIRCursorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1BD1F0D48AC0013853F /* FIRCursorTests.m */; };
+		DE03B2F01F214BAA00A30B9C /* FIRDatabaseTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1BE1F0D48AC0013853F /* FIRDatabaseTests.m */; };
+		DE03B2F11F214BAA00A30B9C /* FIRFieldsTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1BF1F0D48AC0013853F /* FIRFieldsTests.m */; };
+		DE03B2F21F214BAA00A30B9C /* FIRListenerRegistrationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1C01F0D48AC0013853F /* FIRListenerRegistrationTests.m */; };
+		DE03B2F31F214BAA00A30B9C /* FIRQueryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1C11F0D48AC0013853F /* FIRQueryTests.m */; };
+		DE03B2F41F214BAA00A30B9C /* FIRServerTimestampTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1C21F0D48AC0013853F /* FIRServerTimestampTests.m */; };
+		DE03B2F51F214BAA00A30B9C /* FIRTypeTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1C31F0D48AC0013853F /* FIRTypeTests.m */; };
+		DE03B35E1F21586C00A30B9C /* FSTHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1891F0D48AC0013853F /* FSTHelpers.m */; };
+		DE03B3631F215E1A00A30B9C /* CAcert.pem in Resources */ = {isa = PBXBuildFile; fileRef = DE03B3621F215E1600A30B9C /* CAcert.pem */; };
+		DE0761F81F2FE68D003233AF /* main.swift in Sources */ = {isa = PBXBuildFile; fileRef = DE0761F61F2FE68D003233AF /* main.swift */; };
+		DE2EF0851F3D0B6E003D0CDC /* FSTArraySortedDictionaryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE2EF07E1F3D0B6E003D0CDC /* FSTArraySortedDictionaryTests.m */; };
+		DE2EF0861F3D0B6E003D0CDC /* FSTImmutableSortedDictionary+Testing.m in Sources */ = {isa = PBXBuildFile; fileRef = DE2EF0801F3D0B6E003D0CDC /* FSTImmutableSortedDictionary+Testing.m */; };
+		DE2EF0871F3D0B6E003D0CDC /* FSTImmutableSortedSet+Testing.m in Sources */ = {isa = PBXBuildFile; fileRef = DE2EF0821F3D0B6E003D0CDC /* FSTImmutableSortedSet+Testing.m */; };
+		DE2EF0881F3D0B6E003D0CDC /* FSTTreeSortedDictionaryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE2EF0841F3D0B6E003D0CDC /* FSTTreeSortedDictionaryTests.m */; };
+		DE51B1CC1F0D48C00013853F /* FIRGeoPointTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1841F0D48AC0013853F /* FIRGeoPointTests.m */; };
+		DE51B1CD1F0D48CD0013853F /* FSTDatabaseInfoTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1A91F0D48AC0013853F /* FSTDatabaseInfoTests.m */; };
+		DE51B1CE1F0D48CD0013853F /* FSTEventManagerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1AA1F0D48AC0013853F /* FSTEventManagerTests.m */; };
+		DE51B1CF1F0D48CD0013853F /* FSTQueryListenerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1AB1F0D48AC0013853F /* FSTQueryListenerTests.m */; };
+		DE51B1D01F0D48CD0013853F /* FSTQueryTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1AC1F0D48AC0013853F /* FSTQueryTests.m */; };
+		DE51B1D11F0D48CD0013853F /* FSTTargetIDGeneratorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1AE1F0D48AC0013853F /* FSTTargetIDGeneratorTests.m */; };
+		DE51B1D21F0D48CD0013853F /* FSTTimestampTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1AF1F0D48AC0013853F /* FSTTimestampTests.m */; };
+		DE51B1D31F0D48CD0013853F /* FSTViewSnapshotTest.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1B01F0D48AC0013853F /* FSTViewSnapshotTest.m */; };
+		DE51B1D41F0D48CD0013853F /* FSTViewTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1B11F0D48AC0013853F /* FSTViewTests.m */; };
+		DE51B1D91F0D490D0013853F /* FSTEagerGarbageCollectorTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1631F0D48AC0013853F /* FSTEagerGarbageCollectorTests.m */; };
+		DE51B1DA1F0D490D0013853F /* FSTLevelDBLocalStoreTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1651F0D48AC0013853F /* FSTLevelDBLocalStoreTests.m */; };
+		DE51B1DB1F0D490D0013853F /* FSTLevelDBQueryCacheTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1671F0D48AC0013853F /* FSTLevelDBQueryCacheTests.m */; };
+		DE51B1DC1F0D490D0013853F /* FSTLocalSerializerTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1691F0D48AC0013853F /* FSTLocalSerializerTests.m */; };
+		DE51B1DD1F0D490D0013853F /* FSTLocalStoreTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B16B1F0D48AC0013853F /* FSTLocalStoreTests.m */; };
+		DE51B1DE1F0D490D0013853F /* FSTMemoryLocalStoreTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B16C1F0D48AC0013853F /* FSTMemoryLocalStoreTests.m */; };
+		DE51B1DF1F0D490D0013853F /* FSTMemoryMutationQueueTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B16D1F0D48AC0013853F /* FSTMemoryMutationQueueTests.m */; };
+		DE51B1E01F0D490D0013853F /* FSTMemoryQueryCacheTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B16E1F0D48AC0013853F /* FSTMemoryQueryCacheTests.m */; };
+		DE51B1E11F0D490D0013853F /* FSTMemoryRemoteDocumentCacheTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B16F1F0D48AC0013853F /* FSTMemoryRemoteDocumentCacheTests.m */; };
+		DE51B1E21F0D490D0013853F /* FSTMutationQueueTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1711F0D48AC0013853F /* FSTMutationQueueTests.m */; };
+		DE51B1E31F0D490D0013853F /* FSTPersistenceTestHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1731F0D48AC0013853F /* FSTPersistenceTestHelpers.m */; };
+		DE51B1E41F0D490D0013853F /* FSTQueryCacheTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1751F0D48AC0013853F /* FSTQueryCacheTests.m */; };
+		DE51B1E51F0D490D0013853F /* FSTReferenceSetTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1761F0D48AC0013853F /* FSTReferenceSetTests.m */; };
+		DE51B1E61F0D490D0013853F /* FSTRemoteDocumentCacheTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1781F0D48AC0013853F /* FSTRemoteDocumentCacheTests.m */; };
+		DE51B1E71F0D490D0013853F /* FSTRemoteDocumentChangeBufferTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1791F0D48AC0013853F /* FSTRemoteDocumentChangeBufferTests.m */; };
+		DE51B1E81F0D490D0013853F /* FSTLevelDBKeyTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1641F0D48AC0013853F /* FSTLevelDBKeyTests.mm */; };
+		DE51B1E91F0D490D0013853F /* FSTLevelDBMutationQueueTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1661F0D48AC0013853F /* FSTLevelDBMutationQueueTests.mm */; };
+		DE51B1EA1F0D490D0013853F /* FSTLevelDBRemoteDocumentCacheTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1681F0D48AC0013853F /* FSTLevelDBRemoteDocumentCacheTests.mm */; };
+		DE51B1EB1F0D490D0013853F /* FSTWriteGroupTests.mm in Sources */ = {isa = PBXBuildFile; fileRef = DE51B17A1F0D48AC0013853F /* FSTWriteGroupTests.mm */; };
+		DE51B1EC1F0D49140013853F /* FSTDatabaseIDTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B17C1F0D48AC0013853F /* FSTDatabaseIDTests.m */; };
+		DE51B1ED1F0D49140013853F /* FSTDocumentKeyTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B17D1F0D48AC0013853F /* FSTDocumentKeyTests.m */; };
+		DE51B1EE1F0D49140013853F /* FSTDocumentSetTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B17E1F0D48AC0013853F /* FSTDocumentSetTests.m */; };
+		DE51B1EF1F0D49140013853F /* FSTDocumentTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B17F1F0D48AC0013853F /* FSTDocumentTests.m */; };
+		DE51B1F01F0D49140013853F /* FSTFieldValueTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1801F0D48AC0013853F /* FSTFieldValueTests.m */; };
+		DE51B1F11F0D49140013853F /* FSTMutationTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1811F0D48AC0013853F /* FSTMutationTests.m */; };
+		DE51B1F21F0D49140013853F /* FSTPathTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1821F0D48AC0013853F /* FSTPathTests.m */; };
+		DE51B1F31F0D491B0013853F /* FSTDatastoreTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1B31F0D48AC0013853F /* FSTDatastoreTests.m */; };
+		DE51B1F41F0D491B0013853F /* FSTRemoteEventTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1B41F0D48AC0013853F /* FSTRemoteEventTests.m */; };
+		DE51B1F61F0D491B0013853F /* FSTSerializerBetaTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1B61F0D48AC0013853F /* FSTSerializerBetaTests.m */; };
+		DE51B1F71F0D491B0013853F /* FSTStreamTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1B71F0D48AC0013853F /* FSTStreamTests.m */; };
+		DE51B1F81F0D491F0013853F /* FSTWatchChange+Testing.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1B91F0D48AC0013853F /* FSTWatchChange+Testing.m */; };
+		DE51B1F91F0D491F0013853F /* FSTWatchChangeTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1BA1F0D48AC0013853F /* FSTWatchChangeTests.m */; };
+		DE51B1FA1F0D492C0013853F /* FSTLevelDBSpecTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1941F0D48AC0013853F /* FSTLevelDBSpecTests.m */; };
+		DE51B1FB1F0D492C0013853F /* FSTMemorySpecTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1951F0D48AC0013853F /* FSTMemorySpecTests.m */; };
+		DE51B1FC1F0D492C0013853F /* FSTMockDatastore.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1971F0D48AC0013853F /* FSTMockDatastore.m */; };
+		DE51B1FD1F0D492C0013853F /* FSTSpecTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1991F0D48AC0013853F /* FSTSpecTests.m */; };
+		DE51B1FE1F0D492C0013853F /* FSTSyncEngineTestDriver.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B19B1F0D48AC0013853F /* FSTSyncEngineTestDriver.m */; };
+		DE51B1FF1F0D493A0013853F /* FSTAssertTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1861F0D48AC0013853F /* FSTAssertTests.m */; };
+		DE51B2001F0D493A0013853F /* FSTComparisonTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1871F0D48AC0013853F /* FSTComparisonTests.m */; };
+		DE51B2011F0D493E0013853F /* FSTHelpers.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B1891F0D48AC0013853F /* FSTHelpers.m */; };
+		DE51B2021F0D493E0013853F /* FSTUtilTests.m in Sources */ = {isa = PBXBuildFile; fileRef = DE51B18A1F0D48AC0013853F /* FSTUtilTests.m */; };
+		F104BBD69BC3F0796E3A77C1 /* Pods_Firestore_Tests.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = 69F6A10DBD6187489481CD76 /* Pods_Firestore_Tests.framework */; };
+/* End PBXBuildFile section */
+
+/* Begin PBXContainerItemProxy section */
+		6003F5B3195388D20070C39A /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 6003F582195388D10070C39A /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 6003F589195388D20070C39A;
+			remoteInfo = Firestore;
+		};
+		DE03B2961F2149D600A30B9C /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 6003F582195388D10070C39A /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 6003F589195388D20070C39A;
+			remoteInfo = Firestore;
+		};
+		DE0761F91F2FEE7E003233AF /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 6003F582195388D10070C39A /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = DE0761E31F2FE611003233AF;
+			remoteInfo = SwiftBuildTest;
+		};
+		DE29E7F91F2174DD00909613 /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 6003F582195388D10070C39A /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = 6003F5AD195388D20070C39A;
+			remoteInfo = Firestore_Tests;
+		};
+		DE29E7FB1F2174DD00909613 /* PBXContainerItemProxy */ = {
+			isa = PBXContainerItemProxy;
+			containerPortal = 6003F582195388D10070C39A /* Project object */;
+			proxyType = 1;
+			remoteGlobalIDString = DE03B2941F2149D600A30B9C;
+			remoteInfo = Firestore_IntegrationTests;
+		};
+/* End PBXContainerItemProxy section */
+
+/* Begin PBXFileReference section */
+		04DF37A117F88A9891379ED6 /* Pods-Firestore_Tests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Firestore_Tests.release.xcconfig"; path = "Pods/Target Support Files/Pods-Firestore_Tests/Pods-Firestore_Tests.release.xcconfig"; sourceTree = "<group>"; };
+		12F4357299652983A615F886 /* LICENSE */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = LICENSE; path = ../LICENSE; sourceTree = "<group>"; };
+		32AD40BF6B0E849B07FFD05E /* Pods_SwiftBuildTest.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_SwiftBuildTest.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		3B843E4A1F3930A400548890 /* remote_store_spec_test.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = remote_store_spec_test.json; sourceTree = "<group>"; };
+		42491D7DC8C8CD245CC22B93 /* Pods-SwiftBuildTest.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SwiftBuildTest.debug.xcconfig"; path = "Pods/Target Support Files/Pods-SwiftBuildTest/Pods-SwiftBuildTest.debug.xcconfig"; sourceTree = "<group>"; };
+		4EBC5F5ABE1FD097EFE5E224 /* Pods-Firestore_Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Firestore_Example.release.xcconfig"; path = "Pods/Target Support Files/Pods-Firestore_Example/Pods-Firestore_Example.release.xcconfig"; sourceTree = "<group>"; };
+		54DA129C1F315EE100DD57A1 /* collection_spec_test.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = collection_spec_test.json; sourceTree = "<group>"; };
+		54DA129D1F315EE100DD57A1 /* existence_filter_spec_test.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = existence_filter_spec_test.json; sourceTree = "<group>"; };
+		54DA129E1F315EE100DD57A1 /* limbo_spec_test.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = limbo_spec_test.json; sourceTree = "<group>"; };
+		54DA129F1F315EE100DD57A1 /* limit_spec_test.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = limit_spec_test.json; sourceTree = "<group>"; };
+		54DA12A01F315EE100DD57A1 /* listen_spec_test.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = listen_spec_test.json; sourceTree = "<group>"; };
+		54DA12A11F315EE100DD57A1 /* offline_spec_test.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = offline_spec_test.json; sourceTree = "<group>"; };
+		54DA12A21F315EE100DD57A1 /* orderby_spec_test.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = orderby_spec_test.json; sourceTree = "<group>"; };
+		54DA12A31F315EE100DD57A1 /* persistence_spec_test.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = persistence_spec_test.json; sourceTree = "<group>"; };
+		54DA12A41F315EE100DD57A1 /* resume_token_spec_test.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = resume_token_spec_test.json; sourceTree = "<group>"; };
+		54DA12A51F315EE100DD57A1 /* write_spec_test.json */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = text.json; path = write_spec_test.json; sourceTree = "<group>"; };
+		54DA12B01F315F3800DD57A1 /* FIRValidationTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FIRValidationTests.m; sourceTree = "<group>"; };
+		54E9281C1F33950B00C1953E /* FSTEventAccumulator.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FSTEventAccumulator.h; sourceTree = "<group>"; };
+		54E9281D1F33950B00C1953E /* FSTEventAccumulator.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FSTEventAccumulator.m; sourceTree = "<group>"; };
+		54E9281E1F33950B00C1953E /* FSTIntegrationTestCase.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = FSTIntegrationTestCase.h; sourceTree = "<group>"; };
+		54E9281F1F33950B00C1953E /* FSTIntegrationTestCase.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FSTIntegrationTestCase.m; sourceTree = "<group>"; };
+		54E9282A1F339CAD00C1953E /* XCTestCase+Await.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; path = "XCTestCase+Await.h"; sourceTree = "<group>"; };
+		54E9282B1F339CAD00C1953E /* XCTestCase+Await.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = "XCTestCase+Await.m"; sourceTree = "<group>"; };
+		6003F58A195388D20070C39A /* Firestore_Example.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = Firestore_Example.app; sourceTree = BUILT_PRODUCTS_DIR; };
+		6003F58D195388D20070C39A /* Foundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = Foundation.framework; path = System/Library/Frameworks/Foundation.framework; sourceTree = SDKROOT; };
+		6003F58F195388D20070C39A /* CoreGraphics.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = CoreGraphics.framework; path = System/Library/Frameworks/CoreGraphics.framework; sourceTree = SDKROOT; };
+		6003F591195388D20070C39A /* UIKit.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = UIKit.framework; path = System/Library/Frameworks/UIKit.framework; sourceTree = SDKROOT; };
+		6003F595195388D20070C39A /* Firestore-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Firestore-Info.plist"; sourceTree = "<group>"; };
+		6003F597195388D20070C39A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
+		6003F599195388D20070C39A /* main.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = main.m; sourceTree = "<group>"; };
+		6003F59C195388D20070C39A /* FIRAppDelegate.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FIRAppDelegate.h; sourceTree = "<group>"; };
+		6003F59D195388D20070C39A /* FIRAppDelegate.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FIRAppDelegate.m; sourceTree = "<group>"; };
+		6003F5A5195388D20070C39A /* FIRViewController.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FIRViewController.h; sourceTree = "<group>"; };
+		6003F5A6195388D20070C39A /* FIRViewController.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FIRViewController.m; sourceTree = "<group>"; };
+		6003F5A8195388D20070C39A /* Images.xcassets */ = {isa = PBXFileReference; lastKnownFileType = folder.assetcatalog; path = Images.xcassets; sourceTree = "<group>"; };
+		6003F5AE195388D20070C39A /* Firestore_Tests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Firestore_Tests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+		6003F5AF195388D20070C39A /* XCTest.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = XCTest.framework; path = Library/Frameworks/XCTest.framework; sourceTree = DEVELOPER_DIR; };
+		6003F5B7195388D20070C39A /* Tests-Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = "Tests-Info.plist"; sourceTree = "<group>"; };
+		6003F5B9195388D20070C39A /* en */ = {isa = PBXFileReference; lastKnownFileType = text.plist.strings; name = en; path = en.lproj/InfoPlist.strings; sourceTree = "<group>"; };
+		69F6A10DBD6187489481CD76 /* Pods_Firestore_Tests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Firestore_Tests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		71719F9E1E33DC2100824A3D /* Base */ = {isa = PBXFileReference; lastKnownFileType = file.storyboard; name = Base; path = Base.lproj/LaunchScreen.storyboard; sourceTree = "<group>"; };
+		75A6FE51C1A02DF38F62FAAD /* Pods_Firestore_Example.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Firestore_Example.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		873B8AEA1B1F5CCA007FD442 /* Main.storyboard */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = file.storyboard; name = Main.storyboard; path = Base.lproj/Main.storyboard; sourceTree = "<group>"; };
+		8E002F4AD5D9B6197C940847 /* Firestore.podspec */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = Firestore.podspec; path = ../Firestore.podspec; sourceTree = "<group>"; };
+		9D52E67EE96AA7E5D6F69748 /* Pods-Firestore_IntegrationTests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Firestore_IntegrationTests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Firestore_IntegrationTests/Pods-Firestore_IntegrationTests.debug.xcconfig"; sourceTree = "<group>"; };
+		9EF477AD4B2B643FD320867A /* Pods-Firestore_Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Firestore_Example.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Firestore_Example/Pods-Firestore_Example.debug.xcconfig"; sourceTree = "<group>"; };
+		B2FA635DF5D116A67A7441CD /* Pods_Firestore_IntegrationTests.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_Firestore_IntegrationTests.framework; sourceTree = BUILT_PRODUCTS_DIR; };
+		CE00BABB5A3AAB44A4C209E2 /* Pods-Firestore_Tests.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Firestore_Tests.debug.xcconfig"; path = "Pods/Target Support Files/Pods-Firestore_Tests/Pods-Firestore_Tests.debug.xcconfig"; sourceTree = "<group>"; };
+		D3CC3DC5338DCAF43A211155 /* README.md */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = net.daringfireball.markdown; name = README.md; path = ../README.md; sourceTree = "<group>"; };
+		DB17FEDFB80770611A935A60 /* Pods-Firestore_IntegrationTests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-Firestore_IntegrationTests.release.xcconfig"; path = "Pods/Target Support Files/Pods-Firestore_IntegrationTests/Pods-Firestore_IntegrationTests.release.xcconfig"; sourceTree = "<group>"; };
+		DE03B2E91F2149D600A30B9C /* Firestore_IntegrationTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = Firestore_IntegrationTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
+		DE03B3621F215E1600A30B9C /* CAcert.pem */ = {isa = PBXFileReference; lastKnownFileType = text; path = CAcert.pem; sourceTree = "<group>"; };
+		DE0761E41F2FE611003233AF /* SwiftBuildTest.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = SwiftBuildTest.app; sourceTree = BUILT_PRODUCTS_DIR; };
+		DE0761F61F2FE68D003233AF /* main.swift */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.swift; path = main.swift; sourceTree = "<group>"; };
+		DE2EF07E1F3D0B6E003D0CDC /* FSTArraySortedDictionaryTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = FSTArraySortedDictionaryTests.m; path = ../../third_party/Immutable/Tests/FSTArraySortedDictionaryTests.m; sourceTree = "<group>"; };
+		DE2EF07F1F3D0B6E003D0CDC /* FSTImmutableSortedDictionary+Testing.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "FSTImmutableSortedDictionary+Testing.h"; path = "../../third_party/Immutable/Tests/FSTImmutableSortedDictionary+Testing.h"; sourceTree = "<group>"; };
+		DE2EF0801F3D0B6E003D0CDC /* FSTImmutableSortedDictionary+Testing.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "FSTImmutableSortedDictionary+Testing.m"; path = "../../third_party/Immutable/Tests/FSTImmutableSortedDictionary+Testing.m"; sourceTree = "<group>"; };
+		DE2EF0811F3D0B6E003D0CDC /* FSTImmutableSortedSet+Testing.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "FSTImmutableSortedSet+Testing.h"; path = "../../third_party/Immutable/Tests/FSTImmutableSortedSet+Testing.h"; sourceTree = "<group>"; };
+		DE2EF0821F3D0B6E003D0CDC /* FSTImmutableSortedSet+Testing.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = "FSTImmutableSortedSet+Testing.m"; path = "../../third_party/Immutable/Tests/FSTImmutableSortedSet+Testing.m"; sourceTree = "<group>"; };
+		DE2EF0831F3D0B6E003D0CDC /* FSTLLRBValueNode+Test.h */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.h; name = "FSTLLRBValueNode+Test.h"; path = "../../third_party/Immutable/Tests/FSTLLRBValueNode+Test.h"; sourceTree = "<group>"; };
+		DE2EF0841F3D0B6E003D0CDC /* FSTTreeSortedDictionaryTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; name = FSTTreeSortedDictionaryTests.m; path = ../../third_party/Immutable/Tests/FSTTreeSortedDictionaryTests.m; sourceTree = "<group>"; };
+		DE51B1631F0D48AC0013853F /* FSTEagerGarbageCollectorTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTEagerGarbageCollectorTests.m; sourceTree = "<group>"; };
+		DE51B1641F0D48AC0013853F /* FSTLevelDBKeyTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FSTLevelDBKeyTests.mm; sourceTree = "<group>"; };
+		DE51B1651F0D48AC0013853F /* FSTLevelDBLocalStoreTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTLevelDBLocalStoreTests.m; sourceTree = "<group>"; };
+		DE51B1661F0D48AC0013853F /* FSTLevelDBMutationQueueTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FSTLevelDBMutationQueueTests.mm; sourceTree = "<group>"; };
+		DE51B1671F0D48AC0013853F /* FSTLevelDBQueryCacheTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTLevelDBQueryCacheTests.m; sourceTree = "<group>"; };
+		DE51B1681F0D48AC0013853F /* FSTLevelDBRemoteDocumentCacheTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FSTLevelDBRemoteDocumentCacheTests.mm; sourceTree = "<group>"; };
+		DE51B1691F0D48AC0013853F /* FSTLocalSerializerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTLocalSerializerTests.m; sourceTree = "<group>"; };
+		DE51B16A1F0D48AC0013853F /* FSTLocalStoreTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FSTLocalStoreTests.h; sourceTree = "<group>"; };
+		DE51B16B1F0D48AC0013853F /* FSTLocalStoreTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTLocalStoreTests.m; sourceTree = "<group>"; };
+		DE51B16C1F0D48AC0013853F /* FSTMemoryLocalStoreTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTMemoryLocalStoreTests.m; sourceTree = "<group>"; };
+		DE51B16D1F0D48AC0013853F /* FSTMemoryMutationQueueTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTMemoryMutationQueueTests.m; sourceTree = "<group>"; };
+		DE51B16E1F0D48AC0013853F /* FSTMemoryQueryCacheTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTMemoryQueryCacheTests.m; sourceTree = "<group>"; };
+		DE51B16F1F0D48AC0013853F /* FSTMemoryRemoteDocumentCacheTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTMemoryRemoteDocumentCacheTests.m; sourceTree = "<group>"; };
+		DE51B1701F0D48AC0013853F /* FSTMutationQueueTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FSTMutationQueueTests.h; sourceTree = "<group>"; };
+		DE51B1711F0D48AC0013853F /* FSTMutationQueueTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTMutationQueueTests.m; sourceTree = "<group>"; };
+		DE51B1721F0D48AC0013853F /* FSTPersistenceTestHelpers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FSTPersistenceTestHelpers.h; sourceTree = "<group>"; };
+		DE51B1731F0D48AC0013853F /* FSTPersistenceTestHelpers.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTPersistenceTestHelpers.m; sourceTree = "<group>"; };
+		DE51B1741F0D48AC0013853F /* FSTQueryCacheTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FSTQueryCacheTests.h; sourceTree = "<group>"; };
+		DE51B1751F0D48AC0013853F /* FSTQueryCacheTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTQueryCacheTests.m; sourceTree = "<group>"; };
+		DE51B1761F0D48AC0013853F /* FSTReferenceSetTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTReferenceSetTests.m; sourceTree = "<group>"; };
+		DE51B1771F0D48AC0013853F /* FSTRemoteDocumentCacheTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FSTRemoteDocumentCacheTests.h; sourceTree = "<group>"; };
+		DE51B1781F0D48AC0013853F /* FSTRemoteDocumentCacheTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTRemoteDocumentCacheTests.m; sourceTree = "<group>"; };
+		DE51B1791F0D48AC0013853F /* FSTRemoteDocumentChangeBufferTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTRemoteDocumentChangeBufferTests.m; sourceTree = "<group>"; };
+		DE51B17A1F0D48AC0013853F /* FSTWriteGroupTests.mm */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.cpp.objcpp; path = FSTWriteGroupTests.mm; sourceTree = "<group>"; };
+		DE51B17C1F0D48AC0013853F /* FSTDatabaseIDTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTDatabaseIDTests.m; sourceTree = "<group>"; };
+		DE51B17D1F0D48AC0013853F /* FSTDocumentKeyTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTDocumentKeyTests.m; sourceTree = "<group>"; };
+		DE51B17E1F0D48AC0013853F /* FSTDocumentSetTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTDocumentSetTests.m; sourceTree = "<group>"; };
+		DE51B17F1F0D48AC0013853F /* FSTDocumentTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTDocumentTests.m; sourceTree = "<group>"; };
+		DE51B1801F0D48AC0013853F /* FSTFieldValueTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTFieldValueTests.m; sourceTree = "<group>"; };
+		DE51B1811F0D48AC0013853F /* FSTMutationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTMutationTests.m; sourceTree = "<group>"; };
+		DE51B1821F0D48AC0013853F /* FSTPathTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTPathTests.m; sourceTree = "<group>"; };
+		DE51B1841F0D48AC0013853F /* FIRGeoPointTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FIRGeoPointTests.m; sourceTree = "<group>"; };
+		DE51B1861F0D48AC0013853F /* FSTAssertTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTAssertTests.m; sourceTree = "<group>"; };
+		DE51B1871F0D48AC0013853F /* FSTComparisonTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTComparisonTests.m; sourceTree = "<group>"; };
+		DE51B1881F0D48AC0013853F /* FSTHelpers.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FSTHelpers.h; sourceTree = "<group>"; };
+		DE51B1891F0D48AC0013853F /* FSTHelpers.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTHelpers.m; sourceTree = "<group>"; };
+		DE51B18A1F0D48AC0013853F /* FSTUtilTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTUtilTests.m; sourceTree = "<group>"; };
+		DE51B1941F0D48AC0013853F /* FSTLevelDBSpecTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTLevelDBSpecTests.m; sourceTree = "<group>"; };
+		DE51B1951F0D48AC0013853F /* FSTMemorySpecTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTMemorySpecTests.m; sourceTree = "<group>"; };
+		DE51B1961F0D48AC0013853F /* FSTMockDatastore.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FSTMockDatastore.h; sourceTree = "<group>"; };
+		DE51B1971F0D48AC0013853F /* FSTMockDatastore.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTMockDatastore.m; sourceTree = "<group>"; };
+		DE51B1981F0D48AC0013853F /* FSTSpecTests.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FSTSpecTests.h; sourceTree = "<group>"; };
+		DE51B1991F0D48AC0013853F /* FSTSpecTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTSpecTests.m; sourceTree = "<group>"; };
+		DE51B19A1F0D48AC0013853F /* FSTSyncEngineTestDriver.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = FSTSyncEngineTestDriver.h; sourceTree = "<group>"; };
+		DE51B19B1F0D48AC0013853F /* FSTSyncEngineTestDriver.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTSyncEngineTestDriver.m; sourceTree = "<group>"; };
+		DE51B1A71F0D48AC0013853F /* README.md */ = {isa = PBXFileReference; lastKnownFileType = net.daringfireball.markdown; path = README.md; sourceTree = "<group>"; };
+		DE51B1A91F0D48AC0013853F /* FSTDatabaseInfoTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTDatabaseInfoTests.m; sourceTree = "<group>"; };
+		DE51B1AA1F0D48AC0013853F /* FSTEventManagerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTEventManagerTests.m; sourceTree = "<group>"; };
+		DE51B1AB1F0D48AC0013853F /* FSTQueryListenerTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTQueryListenerTests.m; sourceTree = "<group>"; };
+		DE51B1AC1F0D48AC0013853F /* FSTQueryTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTQueryTests.m; sourceTree = "<group>"; };
+		DE51B1AD1F0D48AC0013853F /* FSTSyncEngine+Testing.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FSTSyncEngine+Testing.h"; sourceTree = "<group>"; };
+		DE51B1AE1F0D48AC0013853F /* FSTTargetIDGeneratorTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTTargetIDGeneratorTests.m; sourceTree = "<group>"; };
+		DE51B1AF1F0D48AC0013853F /* FSTTimestampTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTTimestampTests.m; sourceTree = "<group>"; };
+		DE51B1B01F0D48AC0013853F /* FSTViewSnapshotTest.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTViewSnapshotTest.m; sourceTree = "<group>"; };
+		DE51B1B11F0D48AC0013853F /* FSTViewTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTViewTests.m; sourceTree = "<group>"; };
+		DE51B1B31F0D48AC0013853F /* FSTDatastoreTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTDatastoreTests.m; sourceTree = "<group>"; };
+		DE51B1B41F0D48AC0013853F /* FSTRemoteEventTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTRemoteEventTests.m; sourceTree = "<group>"; };
+		DE51B1B61F0D48AC0013853F /* FSTSerializerBetaTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTSerializerBetaTests.m; sourceTree = "<group>"; };
+		DE51B1B71F0D48AC0013853F /* FSTStreamTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTStreamTests.m; sourceTree = "<group>"; };
+		DE51B1B81F0D48AC0013853F /* FSTWatchChange+Testing.h */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.h; path = "FSTWatchChange+Testing.h"; sourceTree = "<group>"; };
+		DE51B1B91F0D48AC0013853F /* FSTWatchChange+Testing.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = "FSTWatchChange+Testing.m"; sourceTree = "<group>"; };
+		DE51B1BA1F0D48AC0013853F /* FSTWatchChangeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTWatchChangeTests.m; sourceTree = "<group>"; };
+		DE51B1BD1F0D48AC0013853F /* FIRCursorTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FIRCursorTests.m; sourceTree = "<group>"; };
+		DE51B1BE1F0D48AC0013853F /* FIRDatabaseTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FIRDatabaseTests.m; sourceTree = "<group>"; };
+		DE51B1BF1F0D48AC0013853F /* FIRFieldsTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FIRFieldsTests.m; sourceTree = "<group>"; };
+		DE51B1C01F0D48AC0013853F /* FIRListenerRegistrationTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FIRListenerRegistrationTests.m; sourceTree = "<group>"; };
+		DE51B1C11F0D48AC0013853F /* FIRQueryTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FIRQueryTests.m; sourceTree = "<group>"; };
+		DE51B1C21F0D48AC0013853F /* FIRServerTimestampTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FIRServerTimestampTests.m; sourceTree = "<group>"; };
+		DE51B1C31F0D48AC0013853F /* FIRTypeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FIRTypeTests.m; sourceTree = "<group>"; };
+		DE51B1C41F0D48AC0013853F /* FSTDatastoreTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTDatastoreTests.m; sourceTree = "<group>"; };
+		DE51B1C51F0D48AC0013853F /* FSTSmokeTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTSmokeTests.m; sourceTree = "<group>"; };
+		DE51B1C61F0D48AC0013853F /* FSTTransactionTests.m */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.c.objc; path = FSTTransactionTests.m; sourceTree = "<group>"; };
+		DEFE0F471F1F960A0071599A /* FIRWriteBatchTests.m */ = {isa = PBXFileReference; fileEncoding = 4; lastKnownFileType = sourcecode.c.objc; path = FIRWriteBatchTests.m; sourceTree = "<group>"; };
+		F23325524BEAF8D24F78AC88 /* Pods-SwiftBuildTest.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-SwiftBuildTest.release.xcconfig"; path = "Pods/Target Support Files/Pods-SwiftBuildTest/Pods-SwiftBuildTest.release.xcconfig"; sourceTree = "<group>"; };
+/* End PBXFileReference section */
+
+/* Begin PBXFrameworksBuildPhase section */
+		6003F587195388D20070C39A /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				6003F590195388D20070C39A /* CoreGraphics.framework in Frameworks */,
+				6003F592195388D20070C39A /* UIKit.framework in Frameworks */,
+				6003F58E195388D20070C39A /* Foundation.framework in Frameworks */,
+				6ED54761B845349D43DB6B78 /* Pods_Firestore_Example.framework in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		6003F5AB195388D20070C39A /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				6003F5B0195388D20070C39A /* XCTest.framework in Frameworks */,
+				6003F5B2195388D20070C39A /* UIKit.framework in Frameworks */,
+				6003F5B1195388D20070C39A /* Foundation.framework in Frameworks */,
+				F104BBD69BC3F0796E3A77C1 /* Pods_Firestore_Tests.framework in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		DE03B2D31F2149D600A30B9C /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				DE03B2D41F2149D600A30B9C /* XCTest.framework in Frameworks */,
+				DE03B2D51F2149D600A30B9C /* UIKit.framework in Frameworks */,
+				DE03B2D61F2149D600A30B9C /* Foundation.framework in Frameworks */,
+				DE03B2D71F2149D600A30B9C /* Pods_Firestore_Tests.framework in Frameworks */,
+				AFE6114F0D4DAECBA7B7C089 /* Pods_Firestore_IntegrationTests.framework in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		DE0761E11F2FE611003233AF /* Frameworks */ = {
+			isa = PBXFrameworksBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				C4E749275AD0FBDF9F4716A8 /* Pods_SwiftBuildTest.framework in Frameworks */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXFrameworksBuildPhase section */
+
+/* Begin PBXGroup section */
+		6003F581195388D10070C39A = {
+			isa = PBXGroup;
+			children = (
+				60FF7A9C1954A5C5007DD14C /* Podspec Metadata */,
+				6003F593195388D20070C39A /* Example for Firestore */,
+				6003F5B5195388D20070C39A /* Tests */,
+				DE0761E51F2FE611003233AF /* SwiftBuildTest */,
+				6003F58C195388D20070C39A /* Frameworks */,
+				6003F58B195388D20070C39A /* Products */,
+				A47A1BF74A48BCAEAFBCBF1E /* Pods */,
+			);
+			sourceTree = "<group>";
+		};
+		6003F58B195388D20070C39A /* Products */ = {
+			isa = PBXGroup;
+			children = (
+				6003F58A195388D20070C39A /* Firestore_Example.app */,
+				6003F5AE195388D20070C39A /* Firestore_Tests.xctest */,
+				DE03B2E91F2149D600A30B9C /* Firestore_IntegrationTests.xctest */,
+				DE0761E41F2FE611003233AF /* SwiftBuildTest.app */,
+			);
+			name = Products;
+			sourceTree = "<group>";
+		};
+		6003F58C195388D20070C39A /* Frameworks */ = {
+			isa = PBXGroup;
+			children = (
+				6003F58D195388D20070C39A /* Foundation.framework */,
+				6003F58F195388D20070C39A /* CoreGraphics.framework */,
+				6003F591195388D20070C39A /* UIKit.framework */,
+				6003F5AF195388D20070C39A /* XCTest.framework */,
+				75A6FE51C1A02DF38F62FAAD /* Pods_Firestore_Example.framework */,
+				69F6A10DBD6187489481CD76 /* Pods_Firestore_Tests.framework */,
+				B2FA635DF5D116A67A7441CD /* Pods_Firestore_IntegrationTests.framework */,
+				32AD40BF6B0E849B07FFD05E /* Pods_SwiftBuildTest.framework */,
+			);
+			name = Frameworks;
+			sourceTree = "<group>";
+		};
+		6003F593195388D20070C39A /* Example for Firestore */ = {
+			isa = PBXGroup;
+			children = (
+				6003F59C195388D20070C39A /* FIRAppDelegate.h */,
+				6003F59D195388D20070C39A /* FIRAppDelegate.m */,
+				873B8AEA1B1F5CCA007FD442 /* Main.storyboard */,
+				6003F5A5195388D20070C39A /* FIRViewController.h */,
+				6003F5A6195388D20070C39A /* FIRViewController.m */,
+				71719F9D1E33DC2100824A3D /* LaunchScreen.storyboard */,
+				6003F5A8195388D20070C39A /* Images.xcassets */,
+				6003F594195388D20070C39A /* Supporting Files */,
+			);
+			name = "Example for Firestore";
+			path = Firestore;
+			sourceTree = "<group>";
+		};
+		6003F594195388D20070C39A /* Supporting Files */ = {
+			isa = PBXGroup;
+			children = (
+				6003F595195388D20070C39A /* Firestore-Info.plist */,
+				6003F596195388D20070C39A /* InfoPlist.strings */,
+				6003F599195388D20070C39A /* main.m */,
+			);
+			name = "Supporting Files";
+			sourceTree = "<group>";
+		};
+		6003F5B5195388D20070C39A /* Tests */ = {
+			isa = PBXGroup;
+			children = (
+				DE51B1831F0D48AC0013853F /* API */,
+				DE51B1A81F0D48AC0013853F /* Core */,
+				DE2EF06E1F3D07D7003D0CDC /* Immutable */,
+				DE51B1BB1F0D48AC0013853F /* Integration */,
+				DE51B1621F0D48AC0013853F /* Local */,
+				DE51B17B1F0D48AC0013853F /* Model */,
+				DE51B1B21F0D48AC0013853F /* Remote */,
+				DE51B1931F0D48AC0013853F /* SpecTests */,
+				DE51B1851F0D48AC0013853F /* Util */,
+				6003F5B6195388D20070C39A /* Supporting Files */,
+			);
+			path = Tests;
+			sourceTree = "<group>";
+		};
+		6003F5B6195388D20070C39A /* Supporting Files */ = {
+			isa = PBXGroup;
+			children = (
+				6003F5B7195388D20070C39A /* Tests-Info.plist */,
+				6003F5B8195388D20070C39A /* InfoPlist.strings */,
+			);
+			name = "Supporting Files";
+			sourceTree = "<group>";
+		};
+		60FF7A9C1954A5C5007DD14C /* Podspec Metadata */ = {
+			isa = PBXGroup;
+			children = (
+				8E002F4AD5D9B6197C940847 /* Firestore.podspec */,
+				D3CC3DC5338DCAF43A211155 /* README.md */,
+				12F4357299652983A615F886 /* LICENSE */,
+			);
+			name = "Podspec Metadata";
+			sourceTree = "<group>";
+		};
+		A47A1BF74A48BCAEAFBCBF1E /* Pods */ = {
+			isa = PBXGroup;
+			children = (
+				9EF477AD4B2B643FD320867A /* Pods-Firestore_Example.debug.xcconfig */,
+				4EBC5F5ABE1FD097EFE5E224 /* Pods-Firestore_Example.release.xcconfig */,
+				9D52E67EE96AA7E5D6F69748 /* Pods-Firestore_IntegrationTests.debug.xcconfig */,
+				DB17FEDFB80770611A935A60 /* Pods-Firestore_IntegrationTests.release.xcconfig */,
+				CE00BABB5A3AAB44A4C209E2 /* Pods-Firestore_Tests.debug.xcconfig */,
+				04DF37A117F88A9891379ED6 /* Pods-Firestore_Tests.release.xcconfig */,
+				42491D7DC8C8CD245CC22B93 /* Pods-SwiftBuildTest.debug.xcconfig */,
+				F23325524BEAF8D24F78AC88 /* Pods-SwiftBuildTest.release.xcconfig */,
+			);
+			name = Pods;
+			sourceTree = "<group>";
+		};
+		DE0761E51F2FE611003233AF /* SwiftBuildTest */ = {
+			isa = PBXGroup;
+			children = (
+				DE0761F61F2FE68D003233AF /* main.swift */,
+			);
+			path = SwiftBuildTest;
+			sourceTree = "<group>";
+		};
+		DE2EF06E1F3D07D7003D0CDC /* Immutable */ = {
+			isa = PBXGroup;
+			children = (
+				DE2EF07E1F3D0B6E003D0CDC /* FSTArraySortedDictionaryTests.m */,
+				DE2EF07F1F3D0B6E003D0CDC /* FSTImmutableSortedDictionary+Testing.h */,
+				DE2EF0801F3D0B6E003D0CDC /* FSTImmutableSortedDictionary+Testing.m */,
+				DE2EF0811F3D0B6E003D0CDC /* FSTImmutableSortedSet+Testing.h */,
+				DE2EF0821F3D0B6E003D0CDC /* FSTImmutableSortedSet+Testing.m */,
+				DE2EF0831F3D0B6E003D0CDC /* FSTLLRBValueNode+Test.h */,
+				DE2EF0841F3D0B6E003D0CDC /* FSTTreeSortedDictionaryTests.m */,
+			);
+			name = Immutable;
+			sourceTree = "<group>";
+		};
+		DE51B1621F0D48AC0013853F /* Local */ = {
+			isa = PBXGroup;
+			children = (
+				DE51B16A1F0D48AC0013853F /* FSTLocalStoreTests.h */,
+				DE51B1701F0D48AC0013853F /* FSTMutationQueueTests.h */,
+				DE51B1721F0D48AC0013853F /* FSTPersistenceTestHelpers.h */,
+				DE51B1741F0D48AC0013853F /* FSTQueryCacheTests.h */,
+				DE51B1771F0D48AC0013853F /* FSTRemoteDocumentCacheTests.h */,
+				DE51B1631F0D48AC0013853F /* FSTEagerGarbageCollectorTests.m */,
+				DE51B1651F0D48AC0013853F /* FSTLevelDBLocalStoreTests.m */,
+				DE51B1671F0D48AC0013853F /* FSTLevelDBQueryCacheTests.m */,
+				DE51B1691F0D48AC0013853F /* FSTLocalSerializerTests.m */,
+				DE51B16B1F0D48AC0013853F /* FSTLocalStoreTests.m */,
+				DE51B16C1F0D48AC0013853F /* FSTMemoryLocalStoreTests.m */,
+				DE51B16D1F0D48AC0013853F /* FSTMemoryMutationQueueTests.m */,
+				DE51B16E1F0D48AC0013853F /* FSTMemoryQueryCacheTests.m */,
+				DE51B16F1F0D48AC0013853F /* FSTMemoryRemoteDocumentCacheTests.m */,
+				DE51B1711F0D48AC0013853F /* FSTMutationQueueTests.m */,
+				DE51B1731F0D48AC0013853F /* FSTPersistenceTestHelpers.m */,
+				DE51B1751F0D48AC0013853F /* FSTQueryCacheTests.m */,
+				DE51B1761F0D48AC0013853F /* FSTReferenceSetTests.m */,
+				DE51B1781F0D48AC0013853F /* FSTRemoteDocumentCacheTests.m */,
+				DE51B1791F0D48AC0013853F /* FSTRemoteDocumentChangeBufferTests.m */,
+				DE51B1641F0D48AC0013853F /* FSTLevelDBKeyTests.mm */,
+				DE51B1661F0D48AC0013853F /* FSTLevelDBMutationQueueTests.mm */,
+				DE51B1681F0D48AC0013853F /* FSTLevelDBRemoteDocumentCacheTests.mm */,
+				DE51B17A1F0D48AC0013853F /* FSTWriteGroupTests.mm */,
+			);
+			path = Local;
+			sourceTree = "<group>";
+		};
+		DE51B17B1F0D48AC0013853F /* Model */ = {
+			isa = PBXGroup;
+			children = (
+				DE51B17C1F0D48AC0013853F /* FSTDatabaseIDTests.m */,
+				DE51B17D1F0D48AC0013853F /* FSTDocumentKeyTests.m */,
+				DE51B17E1F0D48AC0013853F /* FSTDocumentSetTests.m */,
+				DE51B17F1F0D48AC0013853F /* FSTDocumentTests.m */,
+				DE51B1801F0D48AC0013853F /* FSTFieldValueTests.m */,
+				DE51B1811F0D48AC0013853F /* FSTMutationTests.m */,
+				DE51B1821F0D48AC0013853F /* FSTPathTests.m */,
+			);
+			path = Model;
+			sourceTree = "<group>";
+		};
+		DE51B1831F0D48AC0013853F /* API */ = {
+			isa = PBXGroup;
+			children = (
+				DE51B1841F0D48AC0013853F /* FIRGeoPointTests.m */,
+			);
+			path = API;
+			sourceTree = "<group>";
+		};
+		DE51B1851F0D48AC0013853F /* Util */ = {
+			isa = PBXGroup;
+			children = (
+				54E9281C1F33950B00C1953E /* FSTEventAccumulator.h */,
+				54E9281D1F33950B00C1953E /* FSTEventAccumulator.m */,
+				54E9281E1F33950B00C1953E /* FSTIntegrationTestCase.h */,
+				54E9281F1F33950B00C1953E /* FSTIntegrationTestCase.m */,
+				DE51B1861F0D48AC0013853F /* FSTAssertTests.m */,
+				DE51B1871F0D48AC0013853F /* FSTComparisonTests.m */,
+				DE51B1881F0D48AC0013853F /* FSTHelpers.h */,
+				DE51B1891F0D48AC0013853F /* FSTHelpers.m */,
+				DE51B18A1F0D48AC0013853F /* FSTUtilTests.m */,
+				54E9282A1F339CAD00C1953E /* XCTestCase+Await.h */,
+				54E9282B1F339CAD00C1953E /* XCTestCase+Await.m */,
+			);
+			path = Util;
+			sourceTree = "<group>";
+		};
+		DE51B1931F0D48AC0013853F /* SpecTests */ = {
+			isa = PBXGroup;
+			children = (
+				DE51B1961F0D48AC0013853F /* FSTMockDatastore.h */,
+				DE51B1981F0D48AC0013853F /* FSTSpecTests.h */,
+				DE51B19A1F0D48AC0013853F /* FSTSyncEngineTestDriver.h */,
+				DE51B1941F0D48AC0013853F /* FSTLevelDBSpecTests.m */,
+				DE51B1951F0D48AC0013853F /* FSTMemorySpecTests.m */,
+				DE51B1971F0D48AC0013853F /* FSTMockDatastore.m */,
+				DE51B1991F0D48AC0013853F /* FSTSpecTests.m */,
+				DE51B19B1F0D48AC0013853F /* FSTSyncEngineTestDriver.m */,
+				DE51B19C1F0D48AC0013853F /* json */,
+			);
+			path = SpecTests;
+			sourceTree = "<group>";
+		};
+		DE51B19C1F0D48AC0013853F /* json */ = {
+			isa = PBXGroup;
+			children = (
+				3B843E4A1F3930A400548890 /* remote_store_spec_test.json */,
+				54DA129C1F315EE100DD57A1 /* collection_spec_test.json */,
+				54DA129D1F315EE100DD57A1 /* existence_filter_spec_test.json */,
+				54DA129E1F315EE100DD57A1 /* limbo_spec_test.json */,
+				54DA129F1F315EE100DD57A1 /* limit_spec_test.json */,
+				54DA12A01F315EE100DD57A1 /* listen_spec_test.json */,
+				54DA12A11F315EE100DD57A1 /* offline_spec_test.json */,
+				54DA12A21F315EE100DD57A1 /* orderby_spec_test.json */,
+				54DA12A31F315EE100DD57A1 /* persistence_spec_test.json */,
+				54DA12A41F315EE100DD57A1 /* resume_token_spec_test.json */,
+				54DA12A51F315EE100DD57A1 /* write_spec_test.json */,
+				DE51B1A71F0D48AC0013853F /* README.md */,
+			);
+			path = json;
+			sourceTree = "<group>";
+		};
+		DE51B1A81F0D48AC0013853F /* Core */ = {
+			isa = PBXGroup;
+			children = (
+				DE51B1AD1F0D48AC0013853F /* FSTSyncEngine+Testing.h */,
+				DE51B1A91F0D48AC0013853F /* FSTDatabaseInfoTests.m */,
+				DE51B1AA1F0D48AC0013853F /* FSTEventManagerTests.m */,
+				DE51B1AB1F0D48AC0013853F /* FSTQueryListenerTests.m */,
+				DE51B1AC1F0D48AC0013853F /* FSTQueryTests.m */,
+				DE51B1AE1F0D48AC0013853F /* FSTTargetIDGeneratorTests.m */,
+				DE51B1AF1F0D48AC0013853F /* FSTTimestampTests.m */,
+				DE51B1B01F0D48AC0013853F /* FSTViewSnapshotTest.m */,
+				DE51B1B11F0D48AC0013853F /* FSTViewTests.m */,
+			);
+			path = Core;
+			sourceTree = "<group>";
+		};
+		DE51B1B21F0D48AC0013853F /* Remote */ = {
+			isa = PBXGroup;
+			children = (
+				DE51B1B31F0D48AC0013853F /* FSTDatastoreTests.m */,
+				DE51B1B41F0D48AC0013853F /* FSTRemoteEventTests.m */,
+				DE51B1B61F0D48AC0013853F /* FSTSerializerBetaTests.m */,
+				DE51B1B71F0D48AC0013853F /* FSTStreamTests.m */,
+				DE51B1B81F0D48AC0013853F /* FSTWatchChange+Testing.h */,
+				DE51B1B91F0D48AC0013853F /* FSTWatchChange+Testing.m */,
+				DE51B1BA1F0D48AC0013853F /* FSTWatchChangeTests.m */,
+			);
+			path = Remote;
+			sourceTree = "<group>";
+		};
+		DE51B1BB1F0D48AC0013853F /* Integration */ = {
+			isa = PBXGroup;
+			children = (
+				DE03B3621F215E1600A30B9C /* CAcert.pem */,
+				DE51B1BC1F0D48AC0013853F /* API */,
+				DE51B1C41F0D48AC0013853F /* FSTDatastoreTests.m */,
+				DE51B1C51F0D48AC0013853F /* FSTSmokeTests.m */,
+				DE51B1C61F0D48AC0013853F /* FSTTransactionTests.m */,
+				DE51B1C71F0D48AC0013853F /* Util */,
+			);
+			path = Integration;
+			sourceTree = "<group>";
+		};
+		DE51B1BC1F0D48AC0013853F /* API */ = {
+			isa = PBXGroup;
+			children = (
+				DE51B1BD1F0D48AC0013853F /* FIRCursorTests.m */,
+				DE51B1BE1F0D48AC0013853F /* FIRDatabaseTests.m */,
+				DE51B1BF1F0D48AC0013853F /* FIRFieldsTests.m */,
+				DE51B1C01F0D48AC0013853F /* FIRListenerRegistrationTests.m */,
+				DE51B1C11F0D48AC0013853F /* FIRQueryTests.m */,
+				DE51B1C21F0D48AC0013853F /* FIRServerTimestampTests.m */,
+				DE51B1C31F0D48AC0013853F /* FIRTypeTests.m */,
+				54DA12B01F315F3800DD57A1 /* FIRValidationTests.m */,
+				DEFE0F471F1F960A0071599A /* FIRWriteBatchTests.m */,
+			);
+			path = API;
+			sourceTree = "<group>";
+		};
+		DE51B1C71F0D48AC0013853F /* Util */ = {
+			isa = PBXGroup;
+			children = (
+			);
+			path = Util;
+			sourceTree = "<group>";
+		};
+/* End PBXGroup section */
+
+/* Begin PBXNativeTarget section */
+		6003F589195388D20070C39A /* Firestore_Example */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 6003F5BF195388D20070C39A /* Build configuration list for PBXNativeTarget "Firestore_Example" */;
+			buildPhases = (
+				FAB3416C6DD87D45081EC3E8 /* [CP] Check Pods Manifest.lock */,
+				6003F586195388D20070C39A /* Sources */,
+				6003F587195388D20070C39A /* Frameworks */,
+				6003F588195388D20070C39A /* Resources */,
+				7C5123A9C345ECE100DA21BD /* [CP] Embed Pods Frameworks */,
+				DEB4B96019F51073F0553ABC /* [CP] Copy Pods Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			name = Firestore_Example;
+			productName = Firestore;
+			productReference = 6003F58A195388D20070C39A /* Firestore_Example.app */;
+			productType = "com.apple.product-type.application";
+		};
+		6003F5AD195388D20070C39A /* Firestore_Tests */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = 6003F5C2195388D20070C39A /* Build configuration list for PBXNativeTarget "Firestore_Tests" */;
+			buildPhases = (
+				8D94B6319191CD7344A4D1B9 /* [CP] Check Pods Manifest.lock */,
+				6003F5AA195388D20070C39A /* Sources */,
+				6003F5AB195388D20070C39A /* Frameworks */,
+				6003F5AC195388D20070C39A /* Resources */,
+				BB3FE78ABF533BFC38839A0E /* [CP] Embed Pods Frameworks */,
+				AB3F19DA92555D3399DB07CE /* [CP] Copy Pods Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+				6003F5B4195388D20070C39A /* PBXTargetDependency */,
+			);
+			name = Firestore_Tests;
+			productName = FirestoreTests;
+			productReference = 6003F5AE195388D20070C39A /* Firestore_Tests.xctest */;
+			productType = "com.apple.product-type.bundle.unit-test";
+		};
+		DE03B2941F2149D600A30B9C /* Firestore_IntegrationTests */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = DE03B2E61F2149D600A30B9C /* Build configuration list for PBXNativeTarget "Firestore_IntegrationTests" */;
+			buildPhases = (
+				DE03B2971F2149D600A30B9C /* [CP] Check Pods Manifest.lock */,
+				DE03B2981F2149D600A30B9C /* Sources */,
+				DE03B2D31F2149D600A30B9C /* Frameworks */,
+				DE03B2D81F2149D600A30B9C /* Resources */,
+				DE03B2E41F2149D600A30B9C /* [CP] Embed Pods Frameworks */,
+				DE03B2E51F2149D600A30B9C /* [CP] Copy Pods Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+				DE03B2951F2149D600A30B9C /* PBXTargetDependency */,
+			);
+			name = Firestore_IntegrationTests;
+			productName = FirestoreTests;
+			productReference = DE03B2E91F2149D600A30B9C /* Firestore_IntegrationTests.xctest */;
+			productType = "com.apple.product-type.bundle.unit-test";
+		};
+		DE0761E31F2FE611003233AF /* SwiftBuildTest */ = {
+			isa = PBXNativeTarget;
+			buildConfigurationList = DE0761F51F2FE611003233AF /* Build configuration list for PBXNativeTarget "SwiftBuildTest" */;
+			buildPhases = (
+				8F34C5E63ACEBD784CF82A45 /* [CP] Check Pods Manifest.lock */,
+				DE0761E01F2FE611003233AF /* Sources */,
+				DE0761E11F2FE611003233AF /* Frameworks */,
+				DE0761E21F2FE611003233AF /* Resources */,
+				125BDFEB177CFD41D7A40928 /* [CP] Embed Pods Frameworks */,
+				04C27A4B1FAE812E8153B724 /* [CP] Copy Pods Resources */,
+			);
+			buildRules = (
+			);
+			dependencies = (
+			);
+			name = SwiftBuildTest;
+			productName = SwiftBuildTest;
+			productReference = DE0761E41F2FE611003233AF /* SwiftBuildTest.app */;
+			productType = "com.apple.product-type.application";
+		};
+/* End PBXNativeTarget section */
+
+/* Begin PBXProject section */
+		6003F582195388D10070C39A /* Project object */ = {
+			isa = PBXProject;
+			attributes = {
+				CLASSPREFIX = FIR;
+				LastSwiftUpdateCheck = 0830;
+				LastUpgradeCheck = 0720;
+				ORGANIZATIONNAME = Google;
+				TargetAttributes = {
+					6003F5AD195388D20070C39A = {
+						DevelopmentTeam = EQHXZ8M8AV;
+						TestTargetID = 6003F589195388D20070C39A;
+					};
+					DE03B2941F2149D600A30B9C = {
+						DevelopmentTeam = EQHXZ8M8AV;
+					};
+					DE0761E31F2FE611003233AF = {
+						CreatedOnToolsVersion = 8.3.3;
+						DevelopmentTeam = EQHXZ8M8AV;
+						ProvisioningStyle = Automatic;
+					};
+					DE29E7F51F2174B000909613 = {
+						CreatedOnToolsVersion = 9.0;
+						DevelopmentTeam = EQHXZ8M8AV;
+					};
+				};
+			};
+			buildConfigurationList = 6003F585195388D10070C39A /* Build configuration list for PBXProject "Firestore" */;
+			compatibilityVersion = "Xcode 3.2";
+			developmentRegion = English;
+			hasScannedForEncodings = 0;
+			knownRegions = (
+				en,
+				Base,
+			);
+			mainGroup = 6003F581195388D10070C39A;
+			productRefGroup = 6003F58B195388D20070C39A /* Products */;
+			projectDirPath = "";
+			projectRoot = "";
+			targets = (
+				6003F589195388D20070C39A /* Firestore_Example */,
+				6003F5AD195388D20070C39A /* Firestore_Tests */,
+				DE03B2941F2149D600A30B9C /* Firestore_IntegrationTests */,
+				DE29E7F51F2174B000909613 /* AllTests */,
+				DE0761E31F2FE611003233AF /* SwiftBuildTest */,
+			);
+		};
+/* End PBXProject section */
+
+/* Begin PBXResourcesBuildPhase section */
+		6003F588195388D20070C39A /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				873B8AEB1B1F5CCA007FD442 /* Main.storyboard in Resources */,
+				71719F9F1E33DC2100824A3D /* LaunchScreen.storyboard in Resources */,
+				6003F5A9195388D20070C39A /* Images.xcassets in Resources */,
+				6003F598195388D20070C39A /* InfoPlist.strings in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		6003F5AC195388D20070C39A /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				3B843E4C1F3A182900548890 /* remote_store_spec_test.json in Resources */,
+				54DA12A81F315EE100DD57A1 /* limbo_spec_test.json in Resources */,
+				54DA12AA1F315EE100DD57A1 /* listen_spec_test.json in Resources */,
+				54DA12A61F315EE100DD57A1 /* collection_spec_test.json in Resources */,
+				54DA12AE1F315EE100DD57A1 /* resume_token_spec_test.json in Resources */,
+				6003F5BA195388D20070C39A /* InfoPlist.strings in Resources */,
+				54DA12AF1F315EE100DD57A1 /* write_spec_test.json in Resources */,
+				54DA12AD1F315EE100DD57A1 /* persistence_spec_test.json in Resources */,
+				54DA12AB1F315EE100DD57A1 /* offline_spec_test.json in Resources */,
+				54DA12A71F315EE100DD57A1 /* existence_filter_spec_test.json in Resources */,
+				54DA12AC1F315EE100DD57A1 /* orderby_spec_test.json in Resources */,
+				54DA12A91F315EE100DD57A1 /* limit_spec_test.json in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		DE03B2D81F2149D600A30B9C /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				DE03B2DD1F2149D600A30B9C /* InfoPlist.strings in Resources */,
+				DE03B3631F215E1A00A30B9C /* CAcert.pem in Resources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		DE0761E21F2FE611003233AF /* Resources */ = {
+			isa = PBXResourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXResourcesBuildPhase section */
+
+/* Begin PBXShellScriptBuildPhase section */
+		04C27A4B1FAE812E8153B724 /* [CP] Copy Pods Resources */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputPaths = (
+			);
+			name = "[CP] Copy Pods Resources";
+			outputPaths = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-SwiftBuildTest/Pods-SwiftBuildTest-resources.sh\"\n";
+			showEnvVarsInLog = 0;
+		};
+		125BDFEB177CFD41D7A40928 /* [CP] Embed Pods Frameworks */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputPaths = (
+				"${SRCROOT}/Pods/Target Support Files/Pods-SwiftBuildTest/Pods-SwiftBuildTest-frameworks.sh",
+				"${BUILT_PRODUCTS_DIR}/BoringSSL/openssl.framework",
+				"${BUILT_PRODUCTS_DIR}/FirebaseCommunity/FirebaseCommunity.framework",
+				"${BUILT_PRODUCTS_DIR}/Firestore/Firestore.framework",
+				"${BUILT_PRODUCTS_DIR}/GTMSessionFetcher/GTMSessionFetcher.framework",
+				"${BUILT_PRODUCTS_DIR}/GoogleToolboxForMac/GoogleToolboxForMac.framework",
+				"${BUILT_PRODUCTS_DIR}/Protobuf/Protobuf.framework",
+				"${BUILT_PRODUCTS_DIR}/gRPC/GRPCClient.framework",
+				"${BUILT_PRODUCTS_DIR}/gRPC-Core/grpc.framework",
+				"${BUILT_PRODUCTS_DIR}/gRPC-ProtoRPC/ProtoRPC.framework",
+				"${BUILT_PRODUCTS_DIR}/gRPC-RxLibrary/RxLibrary.framework",
+				"${BUILT_PRODUCTS_DIR}/leveldb-library/leveldb.framework",
+				"${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework",
+			);
+			name = "[CP] Embed Pods Frameworks";
+			outputPaths = (
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/openssl.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCommunity.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Firestore.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GTMSessionFetcher.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleToolboxForMac.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Protobuf.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GRPCClient.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/grpc.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ProtoRPC.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RxLibrary.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/leveldb.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-SwiftBuildTest/Pods-SwiftBuildTest-frameworks.sh\"\n";
+			showEnvVarsInLog = 0;
+		};
+		7C5123A9C345ECE100DA21BD /* [CP] Embed Pods Frameworks */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputPaths = (
+				"${SRCROOT}/Pods/Target Support Files/Pods-Firestore_Example/Pods-Firestore_Example-frameworks.sh",
+				"${BUILT_PRODUCTS_DIR}/BoringSSL/openssl.framework",
+				"${BUILT_PRODUCTS_DIR}/FirebaseCommunity/FirebaseCommunity.framework",
+				"${BUILT_PRODUCTS_DIR}/Firestore/Firestore.framework",
+				"${BUILT_PRODUCTS_DIR}/GTMSessionFetcher/GTMSessionFetcher.framework",
+				"${BUILT_PRODUCTS_DIR}/GoogleToolboxForMac/GoogleToolboxForMac.framework",
+				"${BUILT_PRODUCTS_DIR}/Protobuf/Protobuf.framework",
+				"${BUILT_PRODUCTS_DIR}/gRPC/GRPCClient.framework",
+				"${BUILT_PRODUCTS_DIR}/gRPC-Core/grpc.framework",
+				"${BUILT_PRODUCTS_DIR}/gRPC-ProtoRPC/ProtoRPC.framework",
+				"${BUILT_PRODUCTS_DIR}/gRPC-RxLibrary/RxLibrary.framework",
+				"${BUILT_PRODUCTS_DIR}/leveldb-library/leveldb.framework",
+				"${BUILT_PRODUCTS_DIR}/nanopb/nanopb.framework",
+			);
+			name = "[CP] Embed Pods Frameworks";
+			outputPaths = (
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/openssl.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/FirebaseCommunity.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Firestore.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GTMSessionFetcher.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GoogleToolboxForMac.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/Protobuf.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/GRPCClient.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/grpc.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/ProtoRPC.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/RxLibrary.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/leveldb.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/nanopb.framework",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-Firestore_Example/Pods-Firestore_Example-frameworks.sh\"\n";
+			showEnvVarsInLog = 0;
+		};
+		8D94B6319191CD7344A4D1B9 /* [CP] Check Pods Manifest.lock */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputPaths = (
+				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+				"${PODS_ROOT}/Manifest.lock",
+			);
+			name = "[CP] Check Pods Manifest.lock";
+			outputPaths = (
+				"$(DERIVED_FILE_DIR)/Pods-Firestore_Tests-checkManifestLockResult.txt",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+			showEnvVarsInLog = 0;
+		};
+		8F34C5E63ACEBD784CF82A45 /* [CP] Check Pods Manifest.lock */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputPaths = (
+				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+				"${PODS_ROOT}/Manifest.lock",
+			);
+			name = "[CP] Check Pods Manifest.lock";
+			outputPaths = (
+				"$(DERIVED_FILE_DIR)/Pods-SwiftBuildTest-checkManifestLockResult.txt",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+			showEnvVarsInLog = 0;
+		};
+		AB3F19DA92555D3399DB07CE /* [CP] Copy Pods Resources */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputPaths = (
+			);
+			name = "[CP] Copy Pods Resources";
+			outputPaths = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-Firestore_Tests/Pods-Firestore_Tests-resources.sh\"\n";
+			showEnvVarsInLog = 0;
+		};
+		BB3FE78ABF533BFC38839A0E /* [CP] Embed Pods Frameworks */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputPaths = (
+				"${SRCROOT}/Pods/Target Support Files/Pods-Firestore_Tests/Pods-Firestore_Tests-frameworks.sh",
+				"${BUILT_PRODUCTS_DIR}/leveldb-library/leveldb.framework",
+				"${BUILT_PRODUCTS_DIR}/OCMock/OCMock.framework",
+			);
+			name = "[CP] Embed Pods Frameworks";
+			outputPaths = (
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/leveldb.framework",
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OCMock.framework",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-Firestore_Tests/Pods-Firestore_Tests-frameworks.sh\"\n";
+			showEnvVarsInLog = 0;
+		};
+		DE03B2971F2149D600A30B9C /* [CP] Check Pods Manifest.lock */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputPaths = (
+				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+				"${PODS_ROOT}/Manifest.lock",
+			);
+			name = "[CP] Check Pods Manifest.lock";
+			outputPaths = (
+				"$(DERIVED_FILE_DIR)/Pods-Firestore_IntegrationTests-checkManifestLockResult.txt",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+			showEnvVarsInLog = 0;
+		};
+		DE03B2E41F2149D600A30B9C /* [CP] Embed Pods Frameworks */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputPaths = (
+				"${SRCROOT}/Pods/Target Support Files/Pods-Firestore_IntegrationTests/Pods-Firestore_IntegrationTests-frameworks.sh",
+				"${BUILT_PRODUCTS_DIR}/OCMock/OCMock.framework",
+			);
+			name = "[CP] Embed Pods Frameworks";
+			outputPaths = (
+				"${TARGET_BUILD_DIR}/${FRAMEWORKS_FOLDER_PATH}/OCMock.framework",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-Firestore_IntegrationTests/Pods-Firestore_IntegrationTests-frameworks.sh\"\n";
+			showEnvVarsInLog = 0;
+		};
+		DE03B2E51F2149D600A30B9C /* [CP] Copy Pods Resources */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputPaths = (
+			);
+			name = "[CP] Copy Pods Resources";
+			outputPaths = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-Firestore_IntegrationTests/Pods-Firestore_IntegrationTests-resources.sh\"\n";
+			showEnvVarsInLog = 0;
+		};
+		DEB4B96019F51073F0553ABC /* [CP] Copy Pods Resources */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputPaths = (
+			);
+			name = "[CP] Copy Pods Resources";
+			outputPaths = (
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "\"${SRCROOT}/Pods/Target Support Files/Pods-Firestore_Example/Pods-Firestore_Example-resources.sh\"\n";
+			showEnvVarsInLog = 0;
+		};
+		FAB3416C6DD87D45081EC3E8 /* [CP] Check Pods Manifest.lock */ = {
+			isa = PBXShellScriptBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+			);
+			inputPaths = (
+				"${PODS_PODFILE_DIR_PATH}/Podfile.lock",
+				"${PODS_ROOT}/Manifest.lock",
+			);
+			name = "[CP] Check Pods Manifest.lock";
+			outputPaths = (
+				"$(DERIVED_FILE_DIR)/Pods-Firestore_Example-checkManifestLockResult.txt",
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+			shellPath = /bin/sh;
+			shellScript = "diff \"${PODS_PODFILE_DIR_PATH}/Podfile.lock\" \"${PODS_ROOT}/Manifest.lock\" > /dev/null\nif [ $? != 0 ] ; then\n    # print error to STDERR\n    echo \"error: The sandbox is not in sync with the Podfile.lock. Run 'pod install' or update your CocoaPods installation.\" >&2\n    exit 1\nfi\n# This output is used by Xcode 'outputs' to avoid re-running this script phase.\necho \"SUCCESS\" > \"${SCRIPT_OUTPUT_FILE_0}\"\n";
+			showEnvVarsInLog = 0;
+		};
+/* End PBXShellScriptBuildPhase section */
+
+/* Begin PBXSourcesBuildPhase section */
+		6003F586195388D20070C39A /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				6003F59E195388D20070C39A /* FIRAppDelegate.m in Sources */,
+				6003F5A7195388D20070C39A /* FIRViewController.m in Sources */,
+				6003F59A195388D20070C39A /* main.m in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		6003F5AA195388D20070C39A /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				DE2EF0881F3D0B6E003D0CDC /* FSTTreeSortedDictionaryTests.m in Sources */,
+				DE51B1FD1F0D492C0013853F /* FSTSpecTests.m in Sources */,
+				DE51B2001F0D493A0013853F /* FSTComparisonTests.m in Sources */,
+				DE51B1CC1F0D48C00013853F /* FIRGeoPointTests.m in Sources */,
+				DE51B1E11F0D490D0013853F /* FSTMemoryRemoteDocumentCacheTests.m in Sources */,
+				DE51B1FF1F0D493A0013853F /* FSTAssertTests.m in Sources */,
+				DE51B1D31F0D48CD0013853F /* FSTViewSnapshotTest.m in Sources */,
+				DE51B2021F0D493E0013853F /* FSTUtilTests.m in Sources */,
+				DE51B1F91F0D491F0013853F /* FSTWatchChangeTests.m in Sources */,
+				DE51B1F81F0D491F0013853F /* FSTWatchChange+Testing.m in Sources */,
+				DE51B1EB1F0D490D0013853F /* FSTWriteGroupTests.mm in Sources */,
+				DE51B2011F0D493E0013853F /* FSTHelpers.m in Sources */,
+				DE51B1F61F0D491B0013853F /* FSTSerializerBetaTests.m in Sources */,
+				DE51B1F01F0D49140013853F /* FSTFieldValueTests.m in Sources */,
+				DE2EF0861F3D0B6E003D0CDC /* FSTImmutableSortedDictionary+Testing.m in Sources */,
+				DE51B1DE1F0D490D0013853F /* FSTMemoryLocalStoreTests.m in Sources */,
+				DE51B1EC1F0D49140013853F /* FSTDatabaseIDTests.m in Sources */,
+				54E928221F33952900C1953E /* FSTIntegrationTestCase.m in Sources */,
+				DE51B1ED1F0D49140013853F /* FSTDocumentKeyTests.m in Sources */,
+				DE51B1D41F0D48CD0013853F /* FSTViewTests.m in Sources */,
+				DE51B1F41F0D491B0013853F /* FSTRemoteEventTests.m in Sources */,
+				54E928241F33953300C1953E /* FSTEventAccumulator.m in Sources */,
+				DE51B1D11F0D48CD0013853F /* FSTTargetIDGeneratorTests.m in Sources */,
+				DE51B1EF1F0D49140013853F /* FSTDocumentTests.m in Sources */,
+				DE51B1DC1F0D490D0013853F /* FSTLocalSerializerTests.m in Sources */,
+				DE51B1E71F0D490D0013853F /* FSTRemoteDocumentChangeBufferTests.m in Sources */,
+				DE51B1E51F0D490D0013853F /* FSTReferenceSetTests.m in Sources */,
+				DE51B1EA1F0D490D0013853F /* FSTLevelDBRemoteDocumentCacheTests.mm in Sources */,
+				DE51B1D21F0D48CD0013853F /* FSTTimestampTests.m in Sources */,
+				DE51B1EE1F0D49140013853F /* FSTDocumentSetTests.m in Sources */,
+				DE2EF0851F3D0B6E003D0CDC /* FSTArraySortedDictionaryTests.m in Sources */,
+				DE51B1F11F0D49140013853F /* FSTMutationTests.m in Sources */,
+				DE51B1FB1F0D492C0013853F /* FSTMemorySpecTests.m in Sources */,
+				DE51B1DB1F0D490D0013853F /* FSTLevelDBQueryCacheTests.m in Sources */,
+				54E9282C1F339CAD00C1953E /* XCTestCase+Await.m in Sources */,
+				DE51B1DF1F0D490D0013853F /* FSTMemoryMutationQueueTests.m in Sources */,
+				DE51B1F31F0D491B0013853F /* FSTDatastoreTests.m in Sources */,
+				DE51B1D01F0D48CD0013853F /* FSTQueryTests.m in Sources */,
+				DE2EF0871F3D0B6E003D0CDC /* FSTImmutableSortedSet+Testing.m in Sources */,
+				DE51B1E01F0D490D0013853F /* FSTMemoryQueryCacheTests.m in Sources */,
+				DE51B1E91F0D490D0013853F /* FSTLevelDBMutationQueueTests.mm in Sources */,
+				DE51B1E61F0D490D0013853F /* FSTRemoteDocumentCacheTests.m in Sources */,
+				DE51B1D91F0D490D0013853F /* FSTEagerGarbageCollectorTests.m in Sources */,
+				DE51B1F71F0D491B0013853F /* FSTStreamTests.m in Sources */,
+				DE51B1E21F0D490D0013853F /* FSTMutationQueueTests.m in Sources */,
+				DE51B1E81F0D490D0013853F /* FSTLevelDBKeyTests.mm in Sources */,
+				DE51B1E31F0D490D0013853F /* FSTPersistenceTestHelpers.m in Sources */,
+				DE51B1CF1F0D48CD0013853F /* FSTQueryListenerTests.m in Sources */,
+				DE51B1DA1F0D490D0013853F /* FSTLevelDBLocalStoreTests.m in Sources */,
+				DE51B1FA1F0D492C0013853F /* FSTLevelDBSpecTests.m in Sources */,
+				DE51B1FE1F0D492C0013853F /* FSTSyncEngineTestDriver.m in Sources */,
+				DE51B1FC1F0D492C0013853F /* FSTMockDatastore.m in Sources */,
+				DE51B1CE1F0D48CD0013853F /* FSTEventManagerTests.m in Sources */,
+				DE51B1E41F0D490D0013853F /* FSTQueryCacheTests.m in Sources */,
+				DE51B1CD1F0D48CD0013853F /* FSTDatabaseInfoTests.m in Sources */,
+				DE51B1F21F0D49140013853F /* FSTPathTests.m in Sources */,
+				DE51B1DD1F0D490D0013853F /* FSTLocalStoreTests.m in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		DE03B2981F2149D600A30B9C /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				DE03B2EE1F214BAA00A30B9C /* FIRWriteBatchTests.m in Sources */,
+				DE03B2F01F214BAA00A30B9C /* FIRDatabaseTests.m in Sources */,
+				54E928231F33952D00C1953E /* FSTIntegrationTestCase.m in Sources */,
+				DE03B2F41F214BAA00A30B9C /* FIRServerTimestampTests.m in Sources */,
+				DE03B2F11F214BAA00A30B9C /* FIRFieldsTests.m in Sources */,
+				54E9282D1F339CAD00C1953E /* XCTestCase+Await.m in Sources */,
+				DE03B2EC1F214BA200A30B9C /* FSTDatastoreTests.m in Sources */,
+				54E928251F33953400C1953E /* FSTEventAccumulator.m in Sources */,
+				DE03B2ED1F214BA200A30B9C /* FSTSmokeTests.m in Sources */,
+				DE03B2F31F214BAA00A30B9C /* FIRQueryTests.m in Sources */,
+				DE03B35E1F21586C00A30B9C /* FSTHelpers.m in Sources */,
+				DE03B2F51F214BAA00A30B9C /* FIRTypeTests.m in Sources */,
+				DE03B2EF1F214BAA00A30B9C /* FIRCursorTests.m in Sources */,
+				DE03B2F21F214BAA00A30B9C /* FIRListenerRegistrationTests.m in Sources */,
+				DE03B2C91F2149D600A30B9C /* FSTTransactionTests.m in Sources */,
+				54DA12B11F315F3800DD57A1 /* FIRValidationTests.m in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+		DE0761E01F2FE611003233AF /* Sources */ = {
+			isa = PBXSourcesBuildPhase;
+			buildActionMask = 2147483647;
+			files = (
+				DE0761F81F2FE68D003233AF /* main.swift in Sources */,
+			);
+			runOnlyForDeploymentPostprocessing = 0;
+		};
+/* End PBXSourcesBuildPhase section */
+
+/* Begin PBXTargetDependency section */
+		6003F5B4195388D20070C39A /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = 6003F589195388D20070C39A /* Firestore_Example */;
+			targetProxy = 6003F5B3195388D20070C39A /* PBXContainerItemProxy */;
+		};
+		DE03B2951F2149D600A30B9C /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = 6003F589195388D20070C39A /* Firestore_Example */;
+			targetProxy = DE03B2961F2149D600A30B9C /* PBXContainerItemProxy */;
+		};
+		DE0761FA1F2FEE7E003233AF /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = DE0761E31F2FE611003233AF /* SwiftBuildTest */;
+			targetProxy = DE0761F91F2FEE7E003233AF /* PBXContainerItemProxy */;
+		};
+		DE29E7FA1F2174DD00909613 /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = 6003F5AD195388D20070C39A /* Firestore_Tests */;
+			targetProxy = DE29E7F91F2174DD00909613 /* PBXContainerItemProxy */;
+		};
+		DE29E7FC1F2174DD00909613 /* PBXTargetDependency */ = {
+			isa = PBXTargetDependency;
+			target = DE03B2941F2149D600A30B9C /* Firestore_IntegrationTests */;
+			targetProxy = DE29E7FB1F2174DD00909613 /* PBXContainerItemProxy */;
+		};
+/* End PBXTargetDependency section */
+
+/* Begin PBXVariantGroup section */
+		6003F596195388D20070C39A /* InfoPlist.strings */ = {
+			isa = PBXVariantGroup;
+			children = (
+				6003F597195388D20070C39A /* en */,
+			);
+			name = InfoPlist.strings;
+			sourceTree = "<group>";
+		};
+		6003F5B8195388D20070C39A /* InfoPlist.strings */ = {
+			isa = PBXVariantGroup;
+			children = (
+				6003F5B9195388D20070C39A /* en */,
+			);
+			name = InfoPlist.strings;
+			sourceTree = "<group>";
+		};
+		71719F9D1E33DC2100824A3D /* LaunchScreen.storyboard */ = {
+			isa = PBXVariantGroup;
+			children = (
+				71719F9E1E33DC2100824A3D /* Base */,
+			);
+			name = LaunchScreen.storyboard;
+			sourceTree = "<group>";
+		};
+/* End PBXVariantGroup section */
+
+/* Begin XCBuildConfiguration section */
+		6003F5BD195388D20070C39A /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+				CLANG_CXX_LIBRARY = "libc++";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+				COPY_PHASE_STRIP = NO;
+				ENABLE_TESTABILITY = YES;
+				GCC_C_LANGUAGE_STANDARD = gnu99;
+				GCC_DYNAMIC_NO_PIC = NO;
+				GCC_OPTIMIZATION_LEVEL = 0;
+				GCC_PREPROCESSOR_DEFINITIONS = (
+					"DEBUG=1",
+					"$(inherited)",
+				);
+				GCC_SYMBOLS_PRIVATE_EXTERN = NO;
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				IPHONEOS_DEPLOYMENT_TARGET = 8.0;
+				ONLY_ACTIVE_ARCH = YES;
+				SDKROOT = iphoneos;
+				TARGETED_DEVICE_FAMILY = "1,2";
+			};
+			name = Debug;
+		};
+		6003F5BE195388D20070C39A /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				ALWAYS_SEARCH_USER_PATHS = NO;
+				CLANG_CXX_LANGUAGE_STANDARD = "gnu++0x";
+				CLANG_CXX_LIBRARY = "libc++";
+				CLANG_ENABLE_MODULES = YES;
+				CLANG_ENABLE_OBJC_ARC = YES;
+				CLANG_WARN_BOOL_CONVERSION = YES;
+				CLANG_WARN_CONSTANT_CONVERSION = YES;
+				CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR;
+				CLANG_WARN_EMPTY_BODY = YES;
+				CLANG_WARN_ENUM_CONVERSION = YES;
+				CLANG_WARN_INT_CONVERSION = YES;
+				CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR;
+				CLANG_WARN__DUPLICATE_METHOD_MATCH = YES;
+				"CODE_SIGN_IDENTITY[sdk=iphoneos*]" = "iPhone Developer";
+				COPY_PHASE_STRIP = YES;
+				ENABLE_NS_ASSERTIONS = NO;
+				GCC_C_LANGUAGE_STANDARD = gnu99;
+				GCC_WARN_64_TO_32_BIT_CONVERSION = YES;
+				GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR;
+				GCC_WARN_UNDECLARED_SELECTOR = YES;
+				GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE;
+				GCC_WARN_UNUSED_FUNCTION = YES;
+				GCC_WARN_UNUSED_VARIABLE = YES;
+				IPHONEOS_DEPLOYMENT_TARGET = 8.0;
+				SDKROOT = iphoneos;
+				TARGETED_DEVICE_FAMILY = "1,2";
+				VALIDATE_PRODUCT = YES;
+			};
+			name = Release;
+		};
+		6003F5C0195388D20070C39A /* Debug */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 9EF477AD4B2B643FD320867A /* Pods-Firestore_Example.debug.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				GCC_PRECOMPILE_PREFIX_HEADER = YES;
+				GCC_PREFIX_HEADER = "";
+				HEADER_SEARCH_PATHS = (
+					"$(inherited)",
+					"\"${PODS_ROOT}/Firebase/Firebase/Firebase\"",
+					"\"${PODS_ROOT}/leveldb-library/\"",
+					"\"${PODS_ROOT}/leveldb-library/include\"",
+				);
+				INFOPLIST_FILE = "Firestore/Firestore-Info.plist";
+				MODULE_NAME = ExampleApp;
+				PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo.${PRODUCT_NAME:rfc1034identifier}";
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				WRAPPER_EXTENSION = app;
+			};
+			name = Debug;
+		};
+		6003F5C1195388D20070C39A /* Release */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 4EBC5F5ABE1FD097EFE5E224 /* Pods-Firestore_Example.release.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				GCC_PRECOMPILE_PREFIX_HEADER = YES;
+				GCC_PREFIX_HEADER = "";
+				HEADER_SEARCH_PATHS = (
+					"$(inherited)",
+					"\"${PODS_ROOT}/Firebase/Firebase/Firebase\"",
+					"\"${PODS_ROOT}/leveldb-library/\"",
+					"\"${PODS_ROOT}/leveldb-library/include\"",
+				);
+				INFOPLIST_FILE = "Firestore/Firestore-Info.plist";
+				MODULE_NAME = ExampleApp;
+				PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo.${PRODUCT_NAME:rfc1034identifier}";
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				WRAPPER_EXTENSION = app;
+			};
+			name = Release;
+		};
+		6003F5C3195388D20070C39A /* Debug */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = CE00BABB5A3AAB44A4C209E2 /* Pods-Firestore_Tests.debug.xcconfig */;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				DEVELOPMENT_TEAM = EQHXZ8M8AV;
+				FRAMEWORK_SEARCH_PATHS = (
+					"$(SDKROOT)/Developer/Library/Frameworks",
+					"$(inherited)",
+					"$(DEVELOPER_FRAMEWORKS_DIR)",
+				);
+				GCC_PRECOMPILE_PREFIX_HEADER = YES;
+				GCC_PREFIX_HEADER = "";
+				GCC_PREPROCESSOR_DEFINITIONS = (
+					"$(inherited)",
+					"COCOAPODS=1",
+					"GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS=1",
+				);
+				HEADER_SEARCH_PATHS = (
+					"\"$(inherited)\"",
+					"\"${PODS_ROOT}/../../Source\"",
+					"\"${PODS_ROOT}/../../Source/API\"",
+					"\"${PODS_ROOT}/../../Source/Core\"",
+					"\"${PODS_ROOT}/../../Source/Remote\"",
+					"\"${PODS_ROOT}/../../Source/Model\"",
+					"\"${PODS_ROOT}/../../third_party\"",
+					"\"${PODS_ROOT}/../../third_party/Immutable\"",
+					"\"${PODS_ROOT}/../../\"",
+					"\"${PODS_ROOT}/../../Protos/objc/firestore/local\"",
+					"\"${PODS_ROOT}/../../Protos/objc/google/firestore/v1beta1\"",
+					"\"${PODS_ROOT}/../../Protos/objc/google/api\"",
+					"\"${PODS_ROOT}/../../Protos/objc/google/rpc\"",
+					"\"${PODS_ROOT}/../../Protos/objc/google/type\"",
+				);
+				INFOPLIST_FILE = "Tests/Tests-Info.plist";
+				PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo.${PRODUCT_NAME:rfc1034identifier}";
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Firestore_Example.app/Firestore_Example";
+				WRAPPER_EXTENSION = xctest;
+			};
+			name = Debug;
+		};
+		6003F5C4195388D20070C39A /* Release */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 04DF37A117F88A9891379ED6 /* Pods-Firestore_Tests.release.xcconfig */;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				DEVELOPMENT_TEAM = EQHXZ8M8AV;
+				FRAMEWORK_SEARCH_PATHS = (
+					"$(SDKROOT)/Developer/Library/Frameworks",
+					"$(inherited)",
+					"$(DEVELOPER_FRAMEWORKS_DIR)",
+				);
+				GCC_PRECOMPILE_PREFIX_HEADER = YES;
+				GCC_PREFIX_HEADER = "";
+				GCC_PREPROCESSOR_DEFINITIONS = (
+					"$(inherited)",
+					"COCOAPODS=1",
+					"GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS=1",
+				);
+				HEADER_SEARCH_PATHS = (
+					"\"$(inherited)\"",
+					"\"${PODS_ROOT}/../../Source\"",
+					"\"${PODS_ROOT}/../../Source/API\"",
+					"\"${PODS_ROOT}/../../Source/Core\"",
+					"\"${PODS_ROOT}/../../Source/Remote\"",
+					"\"${PODS_ROOT}/../../Source/Model\"",
+					"\"${PODS_ROOT}/../../third_party\"",
+					"\"${PODS_ROOT}/../../third_party/Immutable\"",
+					"\"${PODS_ROOT}/../../Protos/objc/firebase/datastore/clients/proto\"",
+					"\"${PODS_ROOT}/../../Protos/objc/google/firestore/v1beta1\"",
+					"\"${PODS_ROOT}/../../Protos/objc/google/api\"",
+					"\"${PODS_ROOT}/../../Protos/objc/google/rpc\"",
+					"\"${PODS_ROOT}/../../Protos/objc/google/type\"",
+					"\"${PODS_ROOT}/../../\"",
+				);
+				INFOPLIST_FILE = "Tests/Tests-Info.plist";
+				PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo.${PRODUCT_NAME:rfc1034identifier}";
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Firestore_Example.app/Firestore_Example";
+				WRAPPER_EXTENSION = xctest;
+			};
+			name = Release;
+		};
+		DE03B2E71F2149D600A30B9C /* Debug */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 9D52E67EE96AA7E5D6F69748 /* Pods-Firestore_IntegrationTests.debug.xcconfig */;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				DEVELOPMENT_TEAM = EQHXZ8M8AV;
+				FRAMEWORK_SEARCH_PATHS = (
+					"$(SDKROOT)/Developer/Library/Frameworks",
+					"$(inherited)",
+					"$(DEVELOPER_FRAMEWORKS_DIR)",
+				);
+				GCC_PRECOMPILE_PREFIX_HEADER = YES;
+				GCC_PREFIX_HEADER = "";
+				GCC_PREPROCESSOR_DEFINITIONS = (
+					"$(inherited)",
+					"COCOAPODS=1",
+					"GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS=1",
+				);
+				HEADER_SEARCH_PATHS = (
+					"\"$(inherited)\"",
+					"\"${PODS_ROOT}/../../Source\"",
+					"\"${PODS_ROOT}/../../Source/API\"",
+					"\"${PODS_ROOT}/../../Source/Core\"",
+					"\"${PODS_ROOT}/../../Source/Remote\"",
+					"\"${PODS_ROOT}/../../Source/Model\"",
+					"\"${PODS_ROOT}/../../third_party\"",
+					"\"${PODS_ROOT}/../../third_party/Immutable\"",
+					"\"${PODS_ROOT}/../../Protos/objc/firestore/local\"",
+					"\"${PODS_ROOT}/../../Protos/objc/google/firestore/v1beta1\"",
+					"\"${PODS_ROOT}/../../Protos/objc/google/api\"",
+					"\"${PODS_ROOT}/../../Protos/objc/google/rpc\"",
+					"\"${PODS_ROOT}/../../Protos/objc/google/type\"",
+					"\"${PODS_ROOT}/../../\"",
+				);
+				INFOPLIST_FILE = "Tests/Tests-Info.plist";
+				OTHER_LDFLAGS = (
+					"$(inherited)",
+					"-l\"c++\"",
+					"-framework",
+					"\"OCMock\"",
+					"-framework",
+					"\"leveldb\"",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo.${PRODUCT_NAME:rfc1034identifier}";
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Firestore_Example.app/Firestore_Example";
+				WRAPPER_EXTENSION = xctest;
+			};
+			name = Debug;
+		};
+		DE03B2E81F2149D600A30B9C /* Release */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = DB17FEDFB80770611A935A60 /* Pods-Firestore_IntegrationTests.release.xcconfig */;
+			buildSettings = {
+				BUNDLE_LOADER = "$(TEST_HOST)";
+				DEVELOPMENT_TEAM = EQHXZ8M8AV;
+				FRAMEWORK_SEARCH_PATHS = (
+					"$(SDKROOT)/Developer/Library/Frameworks",
+					"$(inherited)",
+					"$(DEVELOPER_FRAMEWORKS_DIR)",
+				);
+				GCC_PRECOMPILE_PREFIX_HEADER = YES;
+				GCC_PREFIX_HEADER = "";
+				GCC_PREPROCESSOR_DEFINITIONS = (
+					"$(inherited)",
+					"COCOAPODS=1",
+					"GPB_USE_PROTOBUF_FRAMEWORK_IMPORTS=1",
+				);
+				HEADER_SEARCH_PATHS = (
+					"\"$(inherited)\"",
+					"\"${PODS_ROOT}/../../Source\"",
+					"\"${PODS_ROOT}/../../Source/API\"",
+					"\"${PODS_ROOT}/../../Source/Core\"",
+					"\"${PODS_ROOT}/../../Source/Remote\"",
+					"\"${PODS_ROOT}/../../Source/Model\"",
+					"\"${PODS_ROOT}/../../third_party\"",
+					"\"${PODS_ROOT}/../../third_party/Immutable\"",
+					"\"${PODS_ROOT}/../../Protos/objc/firestore/local\"",
+					"\"${PODS_ROOT}/../../Protos/objc/google/firestore/v1beta1\"",
+					"\"${PODS_ROOT}/../../Protos/objc/google/api\"",
+					"\"${PODS_ROOT}/../../Protos/objc/google/rpc\"",
+					"\"${PODS_ROOT}/../../Protos/objc/google/type\"",
+					"\"${PODS_ROOT}/../../\"",
+				);
+				INFOPLIST_FILE = "Tests/Tests-Info.plist";
+				OTHER_LDFLAGS = (
+					"$(inherited)",
+					"-l\"c++\"",
+					"-framework",
+					"\"OCMock\"",
+					"-framework",
+					"\"leveldb\"",
+				);
+				PRODUCT_BUNDLE_IDENTIFIER = "org.cocoapods.demo.${PRODUCT_NAME:rfc1034identifier}";
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				TEST_HOST = "$(BUILT_PRODUCTS_DIR)/Firestore_Example.app/Firestore_Example";
+				WRAPPER_EXTENSION = xctest;
+			};
+			name = Release;
+		};
+		DE0761F31F2FE611003233AF /* Debug */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = 42491D7DC8C8CD245CC22B93 /* Pods-SwiftBuildTest.debug.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				DEBUG_INFORMATION_FORMAT = dwarf;
+				DEVELOPMENT_TEAM = EQHXZ8M8AV;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				GCC_NO_COMMON_BLOCKS = YES;
+				INFOPLIST_FILE = "Firestore/Firestore-Info.plist";
+				IPHONEOS_DEPLOYMENT_TARGET = 10.3;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+				MTL_ENABLE_DEBUG_INFO = YES;
+				PRODUCT_BUNDLE_IDENTIFIER = com.google.SwiftBuildTest;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG;
+				SWIFT_OPTIMIZATION_LEVEL = "-Onone";
+				SWIFT_VERSION = 3.0;
+			};
+			name = Debug;
+		};
+		DE0761F41F2FE611003233AF /* Release */ = {
+			isa = XCBuildConfiguration;
+			baseConfigurationReference = F23325524BEAF8D24F78AC88 /* Pods-SwiftBuildTest.release.xcconfig */;
+			buildSettings = {
+				ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon;
+				CLANG_ANALYZER_NONNULL = YES;
+				CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE;
+				CLANG_WARN_DOCUMENTATION_COMMENTS = YES;
+				CLANG_WARN_INFINITE_RECURSION = YES;
+				CLANG_WARN_SUSPICIOUS_MOVE = YES;
+				CLANG_WARN_UNREACHABLE_CODE = YES;
+				COPY_PHASE_STRIP = NO;
+				DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym";
+				DEVELOPMENT_TEAM = EQHXZ8M8AV;
+				ENABLE_STRICT_OBJC_MSGSEND = YES;
+				GCC_NO_COMMON_BLOCKS = YES;
+				INFOPLIST_FILE = "Firestore/Firestore-Info.plist";
+				IPHONEOS_DEPLOYMENT_TARGET = 10.3;
+				LD_RUNPATH_SEARCH_PATHS = "$(inherited) @executable_path/Frameworks";
+				MTL_ENABLE_DEBUG_INFO = NO;
+				PRODUCT_BUNDLE_IDENTIFIER = com.google.SwiftBuildTest;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+				SWIFT_OPTIMIZATION_LEVEL = "-Owholemodule";
+				SWIFT_VERSION = 3.0;
+			};
+			name = Release;
+		};
+		DE29E7F61F2174B000909613 /* Debug */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				DEVELOPMENT_TEAM = EQHXZ8M8AV;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+			};
+			name = Debug;
+		};
+		DE29E7F71F2174B000909613 /* Release */ = {
+			isa = XCBuildConfiguration;
+			buildSettings = {
+				DEVELOPMENT_TEAM = EQHXZ8M8AV;
+				PRODUCT_NAME = "$(TARGET_NAME)";
+			};
+			name = Release;
+		};
+/* End XCBuildConfiguration section */
+
+/* Begin XCConfigurationList section */
+		6003F585195388D10070C39A /* Build configuration list for PBXProject "Firestore" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				6003F5BD195388D20070C39A /* Debug */,
+				6003F5BE195388D20070C39A /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+		6003F5BF195388D20070C39A /* Build configuration list for PBXNativeTarget "Firestore_Example" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				6003F5C0195388D20070C39A /* Debug */,
+				6003F5C1195388D20070C39A /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+		6003F5C2195388D20070C39A /* Build configuration list for PBXNativeTarget "Firestore_Tests" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				6003F5C3195388D20070C39A /* Debug */,
+				6003F5C4195388D20070C39A /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+		DE03B2E61F2149D600A30B9C /* Build configuration list for PBXNativeTarget "Firestore_IntegrationTests" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				DE03B2E71F2149D600A30B9C /* Debug */,
+				DE03B2E81F2149D600A30B9C /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+		DE0761F51F2FE611003233AF /* Build configuration list for PBXNativeTarget "SwiftBuildTest" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				DE0761F31F2FE611003233AF /* Debug */,
+				DE0761F41F2FE611003233AF /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+		DE29E7F81F2174B000909613 /* Build configuration list for PBXAggregateTarget "AllTests" */ = {
+			isa = XCConfigurationList;
+			buildConfigurations = (
+				DE29E7F61F2174B000909613 /* Debug */,
+				DE29E7F71F2174B000909613 /* Release */,
+			);
+			defaultConfigurationIsVisible = 0;
+			defaultConfigurationName = Release;
+		};
+/* End XCConfigurationList section */
+	};
+	rootObject = 6003F582195388D10070C39A /* Project object */;
+}

+ 111 - 0
Firestore/Example/Firestore.xcodeproj/xcshareddata/xcschemes/AllTests.xcscheme

@@ -0,0 +1,111 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "0900"
+   version = "1.3">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "DE29E7F51F2174B000909613"
+               BuildableName = "AllTests"
+               BlueprintName = "AllTests"
+               ReferencedContainer = "container:Firestore.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      language = ""
+      shouldUseLaunchSchemeArgsEnv = "YES">
+      <Testables>
+         <TestableReference
+            skipped = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "DE03B2941F2149D600A30B9C"
+               BuildableName = "Firestore_IntegrationTests.xctest"
+               BlueprintName = "Firestore_IntegrationTests"
+               ReferencedContainer = "container:Firestore.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
+         <TestableReference
+            skipped = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "6003F5AD195388D20070C39A"
+               BuildableName = "Firestore_Tests.xctest"
+               BlueprintName = "Firestore_Tests"
+               ReferencedContainer = "container:Firestore.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
+      </Testables>
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "DE29E7F51F2174B000909613"
+            BuildableName = "AllTests"
+            BlueprintName = "AllTests"
+            ReferencedContainer = "container:Firestore.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+      <AdditionalOptions>
+      </AdditionalOptions>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      language = ""
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "DE29E7F51F2174B000909613"
+            BuildableName = "AllTests"
+            BlueprintName = "AllTests"
+            ReferencedContainer = "container:Firestore.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+      <AdditionalOptions>
+      </AdditionalOptions>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "DE29E7F51F2174B000909613"
+            BuildableName = "AllTests"
+            BlueprintName = "AllTests"
+            ReferencedContainer = "container:Firestore.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 113 - 0
Firestore/Example/Firestore.xcodeproj/xcshareddata/xcschemes/Firestore-Example.xcscheme

@@ -0,0 +1,113 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "0720"
+   version = "1.3">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "6003F589195388D20070C39A"
+               BuildableName = "Firestore_Example.app"
+               BlueprintName = "Firestore_Example"
+               ReferencedContainer = "container:Firestore.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      language = ""
+      shouldUseLaunchSchemeArgsEnv = "YES">
+      <Testables>
+         <TestableReference
+            skipped = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "6003F5AD195388D20070C39A"
+               BuildableName = "Firestore_Tests.xctest"
+               BlueprintName = "Firestore_Tests"
+               ReferencedContainer = "container:Firestore.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
+         <TestableReference
+            skipped = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "DE03B2941F2149D600A30B9C"
+               BuildableName = "Firestore_IntegrationTests.xctest"
+               BlueprintName = "Firestore_IntegrationTests"
+               ReferencedContainer = "container:Firestore.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
+      </Testables>
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "6003F589195388D20070C39A"
+            BuildableName = "Firestore_Example.app"
+            BlueprintName = "Firestore_Example"
+            ReferencedContainer = "container:Firestore.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+      <AdditionalOptions>
+      </AdditionalOptions>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      language = ""
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "6003F589195388D20070C39A"
+            BuildableName = "Firestore_Example.app"
+            BlueprintName = "Firestore_Example"
+            ReferencedContainer = "container:Firestore.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+      <AdditionalOptions>
+      </AdditionalOptions>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "6003F589195388D20070C39A"
+            BuildableName = "Firestore_Example.app"
+            BlueprintName = "Firestore_Example"
+            ReferencedContainer = "container:Firestore.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 71 - 0
Firestore/Example/Firestore.xcodeproj/xcshareddata/xcschemes/Firestore_IntegrationTests.xcscheme

@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "0900"
+   version = "1.3">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForRunning = "YES"
+            buildForTesting = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "DE03B2941F2149D600A30B9C"
+               BuildableName = "Firestore_IntegrationTests.xctest"
+               BlueprintName = "Firestore_IntegrationTests"
+               ReferencedContainer = "container:Firestore.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      language = ""
+      shouldUseLaunchSchemeArgsEnv = "YES">
+      <Testables>
+         <TestableReference
+            skipped = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "DE03B2941F2149D600A30B9C"
+               BuildableName = "Firestore_IntegrationTests.xctest"
+               BlueprintName = "Firestore_IntegrationTests"
+               ReferencedContainer = "container:Firestore.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
+      </Testables>
+      <AdditionalOptions>
+      </AdditionalOptions>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      language = ""
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+      <AdditionalOptions>
+      </AdditionalOptions>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 71 - 0
Firestore/Example/Firestore.xcodeproj/xcshareddata/xcschemes/Firestore_Tests.xcscheme

@@ -0,0 +1,71 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "0900"
+   version = "1.3">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForRunning = "YES"
+            buildForTesting = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "6003F5AD195388D20070C39A"
+               BuildableName = "Firestore_Tests.xctest"
+               BlueprintName = "Firestore_Tests"
+               ReferencedContainer = "container:Firestore.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      language = ""
+      shouldUseLaunchSchemeArgsEnv = "YES">
+      <Testables>
+         <TestableReference
+            skipped = "NO">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "6003F5AD195388D20070C39A"
+               BuildableName = "Firestore_Tests.xctest"
+               BlueprintName = "Firestore_Tests"
+               ReferencedContainer = "container:Firestore.xcodeproj">
+            </BuildableReference>
+         </TestableReference>
+      </Testables>
+      <AdditionalOptions>
+      </AdditionalOptions>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      language = ""
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+      <AdditionalOptions>
+      </AdditionalOptions>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 91 - 0
Firestore/Example/Firestore.xcodeproj/xcshareddata/xcschemes/SwiftBuildTest.xcscheme

@@ -0,0 +1,91 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<Scheme
+   LastUpgradeVersion = "0830"
+   version = "1.3">
+   <BuildAction
+      parallelizeBuildables = "YES"
+      buildImplicitDependencies = "YES">
+      <BuildActionEntries>
+         <BuildActionEntry
+            buildForTesting = "YES"
+            buildForRunning = "YES"
+            buildForProfiling = "YES"
+            buildForArchiving = "YES"
+            buildForAnalyzing = "YES">
+            <BuildableReference
+               BuildableIdentifier = "primary"
+               BlueprintIdentifier = "DE0761E31F2FE611003233AF"
+               BuildableName = "SwiftBuildTest.app"
+               BlueprintName = "SwiftBuildTest"
+               ReferencedContainer = "container:Firestore.xcodeproj">
+            </BuildableReference>
+         </BuildActionEntry>
+      </BuildActionEntries>
+   </BuildAction>
+   <TestAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      shouldUseLaunchSchemeArgsEnv = "YES">
+      <Testables>
+      </Testables>
+      <MacroExpansion>
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "DE0761E31F2FE611003233AF"
+            BuildableName = "SwiftBuildTest.app"
+            BlueprintName = "SwiftBuildTest"
+            ReferencedContainer = "container:Firestore.xcodeproj">
+         </BuildableReference>
+      </MacroExpansion>
+      <AdditionalOptions>
+      </AdditionalOptions>
+   </TestAction>
+   <LaunchAction
+      buildConfiguration = "Debug"
+      selectedDebuggerIdentifier = "Xcode.DebuggerFoundation.Debugger.LLDB"
+      selectedLauncherIdentifier = "Xcode.DebuggerFoundation.Launcher.LLDB"
+      launchStyle = "0"
+      useCustomWorkingDirectory = "NO"
+      ignoresPersistentStateOnLaunch = "NO"
+      debugDocumentVersioning = "YES"
+      debugServiceExtension = "internal"
+      allowLocationSimulation = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "DE0761E31F2FE611003233AF"
+            BuildableName = "SwiftBuildTest.app"
+            BlueprintName = "SwiftBuildTest"
+            ReferencedContainer = "container:Firestore.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+      <AdditionalOptions>
+      </AdditionalOptions>
+   </LaunchAction>
+   <ProfileAction
+      buildConfiguration = "Release"
+      shouldUseLaunchSchemeArgsEnv = "YES"
+      savedToolIdentifier = ""
+      useCustomWorkingDirectory = "NO"
+      debugDocumentVersioning = "YES">
+      <BuildableProductRunnable
+         runnableDebuggingMode = "0">
+         <BuildableReference
+            BuildableIdentifier = "primary"
+            BlueprintIdentifier = "DE0761E31F2FE611003233AF"
+            BuildableName = "SwiftBuildTest.app"
+            BlueprintName = "SwiftBuildTest"
+            ReferencedContainer = "container:Firestore.xcodeproj">
+         </BuildableReference>
+      </BuildableProductRunnable>
+   </ProfileAction>
+   <AnalyzeAction
+      buildConfiguration = "Debug">
+   </AnalyzeAction>
+   <ArchiveAction
+      buildConfiguration = "Release"
+      revealArchiveInOrganizer = "YES">
+   </ArchiveAction>
+</Scheme>

+ 27 - 0
Firestore/Example/Firestore/Base.lproj/LaunchScreen.storyboard

@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="10117" systemVersion="16C67" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" launchScreen="YES" useTraitCollections="YES" initialViewController="01J-lp-oVM">
+    <dependencies>
+        <deployment identifier="iOS"/>
+        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="10085"/>
+    </dependencies>
+    <scenes>
+        <!--View Controller-->
+        <scene sceneID="EHf-IW-A2E">
+            <objects>
+                <viewController id="01J-lp-oVM" sceneMemberID="viewController">
+                    <layoutGuides>
+                        <viewControllerLayoutGuide type="top" id="Llm-lL-Icb"/>
+                        <viewControllerLayoutGuide type="bottom" id="xb3-aO-Qok"/>
+                    </layoutGuides>
+                    <view key="view" contentMode="scaleToFill" id="Ze5-6b-2t3">
+                        <rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
+                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+                        <color key="backgroundColor" white="1" alpha="1" colorSpace="custom" customColorSpace="calibratedWhite"/>
+                    </view>
+                </viewController>
+                <placeholder placeholderIdentifier="IBFirstResponder" id="iYj-Kq-Ea1" userLabel="First Responder" sceneMemberID="firstResponder"/>
+            </objects>
+            <point key="canvasLocation" x="53" y="375"/>
+        </scene>
+    </scenes>
+</document>

+ 27 - 0
Firestore/Example/Firestore/Base.lproj/Main.storyboard

@@ -0,0 +1,27 @@
+<?xml version="1.0" encoding="UTF-8" standalone="no"?>
+<document type="com.apple.InterfaceBuilder3.CocoaTouch.Storyboard.XIB" version="3.0" toolsVersion="7706" systemVersion="14D136" targetRuntime="iOS.CocoaTouch" propertyAccessControl="none" useAutolayout="YES" useTraitCollections="YES" initialViewController="whP-gf-Uak">
+    <dependencies>
+        <deployment identifier="iOS"/>
+        <plugIn identifier="com.apple.InterfaceBuilder.IBCocoaTouchPlugin" version="7703"/>
+    </dependencies>
+    <scenes>
+        <!--View Controller-->
+        <scene sceneID="wQg-tq-qST">
+            <objects>
+                <viewController id="whP-gf-Uak" customClass="FIRViewController" sceneMemberID="viewController">
+                    <layoutGuides>
+                        <viewControllerLayoutGuide type="top" id="uEw-UM-LJ8"/>
+                        <viewControllerLayoutGuide type="bottom" id="Mvr-aV-6Um"/>
+                    </layoutGuides>
+                    <view key="view" contentMode="scaleToFill" id="TpU-gO-2f1">
+                        <rect key="frame" x="0.0" y="0.0" width="600" height="600"/>
+                        <autoresizingMask key="autoresizingMask" widthSizable="YES" heightSizable="YES"/>
+                        <color key="backgroundColor" white="1" alpha="1" colorSpace="calibratedWhite"/>
+                    </view>
+                </viewController>
+                <placeholder placeholderIdentifier="IBFirstResponder" id="tc2-Qw-aMS" userLabel="First Responder" sceneMemberID="firstResponder"/>
+            </objects>
+            <point key="canvasLocation" x="305" y="433"/>
+        </scene>
+    </scenes>
+</document>

+ 23 - 0
Firestore/Example/Firestore/FIRAppDelegate.h

@@ -0,0 +1,23 @@
+/*
+ * 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 UIKit;
+
+@interface FIRAppDelegate : UIResponder <UIApplicationDelegate>
+
+@property(strong, nonatomic) UIWindow *window;
+
+@end

+ 57 - 0
Firestore/Example/Firestore/FIRAppDelegate.m

@@ -0,0 +1,57 @@
+/*
+ * 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 "FIRAppDelegate.h"
+
+@implementation FIRAppDelegate
+
+- (BOOL)application:(UIApplication *)application
+    didFinishLaunchingWithOptions:(NSDictionary *)launchOptions {
+  // Override point for customization after application launch.
+  return YES;
+}
+
+- (void)applicationWillResignActive:(UIApplication *)application {
+  // Sent when the application is about to move from active to inactive state. This can occur for
+  // certain types of temporary interruptions (such as an incoming phone call or SMS message) or
+  // when the user quits the application and it begins the transition to the background state. Use
+  // this method to pause ongoing tasks, disable timers, and throttle down OpenGL ES frame rates.
+  // Games should use this method to pause the game.
+}
+
+- (void)applicationDidEnterBackground:(UIApplication *)application {
+  // Use this method to release shared resources, save user data, invalidate timers, and store
+  // enough application state information to restore your application to its current state in case
+  // it is terminated later. If your application supports background execution, this method is
+  // called instead of applicationWillTerminate: when the user quits.
+}
+
+- (void)applicationWillEnterForeground:(UIApplication *)application {
+  // Called as part of the transition from the background to the inactive state; here you can undo
+  // many of the changes made on entering the background.
+}
+
+- (void)applicationDidBecomeActive:(UIApplication *)application {
+  // Restart any tasks that were paused (or not yet started) while the application was inactive. If
+  // the application was previously in the background, optionally refresh the user interface.
+}
+
+- (void)applicationWillTerminate:(UIApplication *)application {
+  // Called when the application is about to terminate. Save data if appropriate. See also
+  // applicationDidEnterBackground:.
+}
+
+@end

+ 21 - 0
Firestore/Example/Firestore/FIRViewController.h

@@ -0,0 +1,21 @@
+/*
+ * 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 UIKit;
+
+@interface FIRViewController : UIViewController
+
+@end

+ 35 - 0
Firestore/Example/Firestore/FIRViewController.m

@@ -0,0 +1,35 @@
+/*
+ * 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 "FIRViewController.h"
+
+@interface FIRViewController ()
+
+@end
+
+@implementation FIRViewController
+
+- (void)viewDidLoad {
+  [super viewDidLoad];
+  // Do any additional setup after loading the view, typically from a nib.
+}
+
+- (void)didReceiveMemoryWarning {
+  [super didReceiveMemoryWarning];
+  // Dispose of any resources that can be recreated.
+}
+
+@end

+ 49 - 0
Firestore/Example/Firestore/Firestore-Info.plist

@@ -0,0 +1,49 @@
+<?xml version="1.0" encoding="UTF-8"?>
+<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
+<plist version="1.0">
+<dict>
+	<key>CFBundleDevelopmentRegion</key>
+	<string>en</string>
+	<key>CFBundleDisplayName</key>
+	<string>${PRODUCT_NAME}</string>
+	<key>CFBundleExecutable</key>
+	<string>${EXECUTABLE_NAME}</string>
+	<key>CFBundleIdentifier</key>
+	<string>$(PRODUCT_BUNDLE_IDENTIFIER)</string>
+	<key>CFBundleInfoDictionaryVersion</key>
+	<string>6.0</string>
+	<key>CFBundleName</key>
+	<string>${PRODUCT_NAME}</string>
+	<key>CFBundlePackageType</key>
+	<string>APPL</string>
+	<key>CFBundleShortVersionString</key>
+	<string>1.0</string>
+	<key>CFBundleSignature</key>
+	<string>????</string>
+	<key>CFBundleVersion</key>
+	<string>1.0</string>
+	<key>LSRequiresIPhoneOS</key>
+	<true/>
+	<key>UILaunchStoryboardName</key>
+	<string>LaunchScreen</string>
+	<key>UIMainStoryboardFile</key>
+	<string>Main</string>
+	<key>UIRequiredDeviceCapabilities</key>
+	<array>
+		<string>armv7</string>
+	</array>
+	<key>UISupportedInterfaceOrientations</key>
+	<array>
+		<string>UIInterfaceOrientationPortrait</string>
+		<string>UIInterfaceOrientationLandscapeLeft</string>
+		<string>UIInterfaceOrientationLandscapeRight</string>
+	</array>
+	<key>UISupportedInterfaceOrientations~ipad</key>
+	<array>
+		<string>UIInterfaceOrientationPortrait</string>
+		<string>UIInterfaceOrientationPortraitUpsideDown</string>
+		<string>UIInterfaceOrientationLandscapeLeft</string>
+		<string>UIInterfaceOrientationLandscapeRight</string>
+	</array>
+</dict>
+</plist>

+ 93 - 0
Firestore/Example/Firestore/Images.xcassets/AppIcon.appiconset/Contents.json

@@ -0,0 +1,93 @@
+{
+  "images" : [
+    {
+      "idiom" : "iphone",
+      "size" : "20x20",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "iphone",
+      "size" : "20x20",
+      "scale" : "3x"
+    },
+    {
+      "idiom" : "iphone",
+      "size" : "29x29",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "iphone",
+      "size" : "29x29",
+      "scale" : "3x"
+    },
+    {
+      "idiom" : "iphone",
+      "size" : "40x40",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "iphone",
+      "size" : "40x40",
+      "scale" : "3x"
+    },
+    {
+      "idiom" : "iphone",
+      "size" : "60x60",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "iphone",
+      "size" : "60x60",
+      "scale" : "3x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "20x20",
+      "scale" : "1x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "20x20",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "29x29",
+      "scale" : "1x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "29x29",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "40x40",
+      "scale" : "1x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "40x40",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "76x76",
+      "scale" : "1x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "76x76",
+      "scale" : "2x"
+    },
+    {
+      "idiom" : "ipad",
+      "size" : "83.5x83.5",
+      "scale" : "2x"
+    }
+  ],
+  "info" : {
+    "version" : 1,
+    "author" : "xcode"
+  }
+}

+ 2 - 0
Firestore/Example/Firestore/en.lproj/InfoPlist.strings

@@ -0,0 +1,2 @@
+/* Localized versions of Info.plist keys */
+

+ 24 - 0
Firestore/Example/Firestore/main.m

@@ -0,0 +1,24 @@
+/*
+ * 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 UIKit;
+#import "FIRAppDelegate.h"
+
+int main(int argc, char* argv[]) {
+  @autoreleasepool {
+    return UIApplicationMain(argc, argv, nil, NSStringFromClass([FIRAppDelegate class]));
+  }
+}

+ 22 - 0
Firestore/Example/Podfile

@@ -0,0 +1,22 @@
+use_frameworks!
+platform :ios, '8.0'
+
+target 'Firestore_Example' do
+  pod 'FirebaseCommunity/Core', :path => '../../'
+  pod 'Firestore', :path => '../'
+
+  target 'Firestore_Tests' do
+    inherit! :search_paths
+    pod 'OCMock'
+    pod 'leveldb-library'
+  end
+
+  target 'Firestore_IntegrationTests' do
+    inherit! :search_paths
+    pod 'OCMock'
+  end
+end
+
+target 'SwiftBuildTest' do
+  pod 'Firestore', :path => '../'
+end

+ 284 - 0
Firestore/Example/SwiftBuildTest/main.swift

@@ -0,0 +1,284 @@
+/*
+ * 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
+
+import Firestore
+
+func main() {
+    let db = initializeDb();
+
+    let (collectionRef, documentRef) = makeRefs(database: db);
+
+    let query = makeQuery(collection: collectionRef);
+
+    writeDocument(at: documentRef);
+
+    addDocument(to: collectionRef);
+
+    readDocument(at: documentRef);
+
+    readDocuments(matching: query);
+
+    listenToDocument(at: documentRef);
+
+    listenToDocuments(matching: query);
+
+    types();
+}
+
+func initializeDb() -> Firestore {
+
+    // Initialize with ProjectID.
+    let firestore = Firestore.firestore()
+
+    // Apply settings
+    let settings = FirestoreSettings()
+    settings.host = "localhost"
+    settings.isPersistenceEnabled = true
+    firestore.settings = settings
+
+    return firestore;
+}
+
+func makeRefs(database db: Firestore) -> (CollectionReference, DocumentReference) {
+
+    var collectionRef = db.collection("my-collection")
+
+    var documentRef: DocumentReference;
+    documentRef = collectionRef.document("my-doc")
+    // or
+    documentRef = db.document("my-collection/my-doc")
+
+    // deeper collection (my-collection/my-doc/some/deep/collection)
+    collectionRef = documentRef.collection("some/deep/collection")
+
+    // parent doc (my-collection/my-doc/some/deep)
+    documentRef = collectionRef.parent!
+
+    // print paths.
+    print("Collection: \(collectionRef.path), document: \(documentRef.path)")
+
+    return (collectionRef, documentRef);
+}
+
+func makeQuery(collection collectionRef: CollectionReference) -> Query {
+
+    let query = collectionRef.whereField(FieldPath(["name"]), isEqualTo: "Fred")
+        .whereField("age", isGreaterThanOrEqualTo: 24)
+        .whereField(FieldPath.documentID(), isEqualTo: "fred")
+        .order(by: FieldPath(["age"]))
+        .order(by: "name", descending: true)
+        .limit(to: 10)
+
+    return query;
+}
+
+func writeDocument(at docRef: DocumentReference) {
+
+    let setData = [
+        "foo": 42,
+        "bar": [
+            "baz": "Hello world!"
+        ]
+    ] as [String : Any];
+
+    let  updateData = [
+        "bar.baz": 42,
+        FieldPath(["foobar"]) : 42
+    ] as [AnyHashable : Any];
+
+    docRef.setData(setData)
+
+    // Completion callback (via trailing closure syntax).
+    docRef.setData(setData) { error in
+        if let error = error {
+            print("Uh oh! \(error)")
+            return
+        }
+
+        print("Set complete!")
+    }
+
+    // SetOptions
+    docRef.setData(setData, options:SetOptions.merge())
+
+    docRef.updateData(updateData)
+    docRef.delete();
+
+    docRef.delete() { error in
+        if let error = error {
+            print("Uh oh! \(error)")
+            return
+        }
+
+        print("Set complete!")
+    }
+}
+
+func addDocument(to collectionRef: CollectionReference) {
+
+    collectionRef.addDocument(data: ["foo": 42]);
+    //or
+    collectionRef.document().setData(["foo": 42]);
+}
+
+func readDocument(at docRef: DocumentReference) {
+
+    // Trailing closure syntax.
+    docRef.getDocument() { document, error in
+        if let document = document {
+            // NOTE that document is nullable.
+            let data = document.data();
+            print("Read document: \(data)")
+
+            // Fields are read via subscript notation.
+          if let foo = document["foo"] {
+            print("Field: \(foo)")
+          }
+        } else {
+            // TODO(mikelehen): There may be a better way to do this, but it at least demonstrates
+            // the swift error domain / enum codes are renamed appropriately.
+            if let errorCode = error.flatMap({
+                ($0._domain == FirestoreErrorDomain) ? FirestoreErrorCode (rawValue: $0._code) : nil
+            }) {
+                switch errorCode {
+                case .unavailable:
+                    print("Can't read document due to being offline!")
+                case _:
+                    print("Failed to read.")
+                }
+            } else {
+                print("Unknown error!")
+            }
+        }
+
+    }
+}
+
+func readDocuments(matching query: Query) {
+    query.getDocuments() { querySnapshot, error in
+        // TODO(mikelehen): Figure out how to make "for..in" syntax work
+        // directly on documentSet.
+        for document in querySnapshot!.documents {
+            print(document.data())
+        }
+    }
+}
+
+func listenToDocument(at docRef: DocumentReference) {
+
+    let listener = docRef.addSnapshotListener() { document, error in
+        if let error = error {
+            print("Uh oh! Listen canceled: \(error)")
+            return
+        }
+
+        if let document = document {
+            print("Current document: \(document.data())");
+            if (document.metadata.isFromCache) {
+                print("From Cache")
+            } else {
+                print("From Server")
+            }
+        }
+    }
+
+    // Unsubscribe.
+    listener.remove();
+}
+
+func listenToDocuments(matching query: Query) {
+
+    let listener = query.addSnapshotListener() { snap, error in
+        if let error = error {
+            print("Uh oh! Listen canceled: \(error)")
+            return
+        }
+
+        if let snap = snap {
+            print("NEW SNAPSHOT (empty=\(snap.isEmpty) count=\(snap.count)")
+
+            // TODO(mikelehen): Figure out how to make "for..in" syntax work
+            // directly on documentSet.
+            for document in snap.documents {
+              print("Doc: ", document.data())
+            }
+        }
+    }
+
+    // Unsubscribe
+    listener.remove();
+}
+
+func listenToQueryDiffs(onQuery query: Query) {
+
+    let listener = query.addSnapshotListener() { snap, error in
+        if let snap = snap {
+            for change in snap.documentChanges {
+                switch (change.type) {
+                case .added:
+                    print("New document: \(change.document.data())")
+                case .modified:
+                    print("Modified document: \(change.document.data())")
+                case .removed:
+                    print("Removed document: \(change.document.data())")
+                }
+            }
+        }
+    }
+
+    // Unsubscribe
+    listener.remove();
+}
+
+func transactions() {
+    let db = Firestore.firestore()
+
+    let collectionRef = db.collection("cities")
+    let accA = collectionRef.document("accountA")
+    let accB = collectionRef.document("accountB")
+    let amount = 20.0
+
+    db.runTransaction({ (transaction, errorPointer) -> Any? in
+        do {
+            let balanceA = try transaction.getDocument(accA)["balance"] as! Double
+            let balanceB = try transaction.getDocument(accB)["balance"] as! Double
+
+            if (balanceA < amount) {
+                errorPointer?.pointee = NSError(domain: "Foo", code: 123, userInfo: nil)
+                return nil
+            }
+            transaction.updateData(["balance": balanceA - amount], forDocument:accA)
+            transaction.updateData(["balance": balanceB + amount], forDocument:accB)
+        } catch let error as NSError {
+            print("Uh oh! \(error)")
+        }
+        return 0
+    }) { (result, error) in
+        // handle result.
+    }
+}
+
+func types() {
+    // Just highlighting the types of everything, though devs can/will often omit them.
+    let _: Firestore;
+    let _: CollectionReference;
+    let _: DocumentReference;
+    let _: Query;
+    let _: DocumentSnapshot;
+    let _: QuerySnapshot;
+}

+ 67 - 0
Firestore/Example/Tests/API/FIRGeoPointTests.m

@@ -0,0 +1,67 @@
+/*
+ * 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/FIRGeoPoint.h"
+
+#import <XCTest/XCTest.h>
+
+#import "FSTHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FIRGeoPointTests : XCTestCase
+@end
+
+@implementation FIRGeoPointTests
+
+- (void)testEquals {
+  XCTAssertEqualObjects([[FIRGeoPoint alloc] initWithLatitude:0 longitude:0],
+                        [[FIRGeoPoint alloc] initWithLatitude:0 longitude:0]);
+  XCTAssertEqualObjects([[FIRGeoPoint alloc] initWithLatitude:1.23 longitude:4.56],
+                        [[FIRGeoPoint alloc] initWithLatitude:1.23 longitude:4.56]);
+  XCTAssertNotEqualObjects([[FIRGeoPoint alloc] initWithLatitude:0 longitude:0],
+                           [[FIRGeoPoint alloc] initWithLatitude:1 longitude:0]);
+  XCTAssertNotEqualObjects([[FIRGeoPoint alloc] initWithLatitude:0 longitude:0],
+                           [[FIRGeoPoint alloc] initWithLatitude:0 longitude:1]);
+  XCTAssertNotEqualObjects([[FIRGeoPoint alloc] initWithLatitude:0 longitude:0],
+                           [[NSObject alloc] init]);
+}
+
+- (void)testComparison {
+  NSArray *values = @[
+    @[ [[FIRGeoPoint alloc] initWithLatitude:-90 longitude:-180] ],
+    @[ [[FIRGeoPoint alloc] initWithLatitude:-90 longitude:0] ],
+    @[ [[FIRGeoPoint alloc] initWithLatitude:-90 longitude:180] ],
+    @[ [[FIRGeoPoint alloc] initWithLatitude:-89 longitude:-180] ],
+    @[ [[FIRGeoPoint alloc] initWithLatitude:-89 longitude:0] ],
+    @[ [[FIRGeoPoint alloc] initWithLatitude:-89 longitude:180] ],
+    @[ [[FIRGeoPoint alloc] initWithLatitude:0 longitude:-180] ],
+    @[ [[FIRGeoPoint alloc] initWithLatitude:0 longitude:0] ],
+    @[ [[FIRGeoPoint alloc] initWithLatitude:0 longitude:180] ],
+    @[ [[FIRGeoPoint alloc] initWithLatitude:89 longitude:-180] ],
+    @[ [[FIRGeoPoint alloc] initWithLatitude:89 longitude:0] ],
+    @[ [[FIRGeoPoint alloc] initWithLatitude:89 longitude:180] ],
+    @[ [[FIRGeoPoint alloc] initWithLatitude:90 longitude:-180] ],
+    @[ [[FIRGeoPoint alloc] initWithLatitude:90 longitude:0] ],
+    @[ [[FIRGeoPoint alloc] initWithLatitude:90 longitude:180] ],
+  ];
+
+  FSTAssertComparisons(values);
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 59 - 0
Firestore/Example/Tests/Core/FSTDatabaseInfoTests.m

@@ -0,0 +1,59 @@
+/*
+ * 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 "Core/FSTDatabaseInfo.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Model/FSTDatabaseID.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTDatabaseInfoTests : XCTestCase
+@end
+
+@implementation FSTDatabaseInfoTests
+
+- (void)testConstructor {
+  FSTDatabaseID *databaseID = [FSTDatabaseID databaseIDWithProject:@"p" database:@"d"];
+  FSTDatabaseInfo *databaseInfo = [FSTDatabaseInfo databaseInfoWithDatabaseID:databaseID
+                                                               persistenceKey:@"pk"
+                                                                         host:@"h"
+                                                                   sslEnabled:YES];
+  XCTAssertEqualObjects(databaseInfo.databaseID.projectID, @"p");
+  XCTAssertEqualObjects(databaseInfo.databaseID.databaseID, @"d");
+  XCTAssertEqualObjects(databaseInfo.persistenceKey, @"pk");
+  XCTAssertEqualObjects(databaseInfo.host, @"h");
+  XCTAssertEqual(databaseInfo.sslEnabled, YES);
+}
+
+- (void)testDefaultDatabase {
+  FSTDatabaseID *databaseID =
+      [FSTDatabaseID databaseIDWithProject:@"p" database:kDefaultDatabaseID];
+  FSTDatabaseInfo *databaseInfo = [FSTDatabaseInfo databaseInfoWithDatabaseID:databaseID
+                                                               persistenceKey:@"pk"
+                                                                         host:@"h"
+                                                                   sslEnabled:YES];
+  XCTAssertEqualObjects(databaseInfo.databaseID.projectID, @"p");
+  XCTAssertEqualObjects(databaseInfo.databaseID.databaseID, @"(default)");
+  XCTAssertEqualObjects(databaseInfo.persistenceKey, @"pk");
+  XCTAssertEqualObjects(databaseInfo.host, @"h");
+  XCTAssertEqual(databaseInfo.sslEnabled, YES);
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 163 - 0
Firestore/Example/Tests/Core/FSTEventManagerTests.m

@@ -0,0 +1,163 @@
+/*
+ * 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 "Core/FSTEventManager.h"
+
+#import <OCMock/OCMock.h>
+#import <XCTest/XCTest.h>
+
+#import "Core/FSTQuery.h"
+#import "Core/FSTSyncEngine.h"
+#import "Model/FSTDocumentSet.h"
+#import "Util/FSTDispatchQueue.h"
+
+#import "FSTHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+// FSTEventManager implements this delegate privately
+@interface FSTEventManager () <FSTSyncEngineDelegate>
+@end
+
+@interface FSTEventManagerTests : XCTestCase
+@end
+
+@implementation FSTEventManagerTests {
+  FSTDispatchQueue *_testUserQueue;
+}
+
+- (void)setUp {
+  _testUserQueue = [FSTDispatchQueue queueWith:dispatch_get_main_queue()];
+}
+
+- (FSTQueryListener *)noopListenerForQuery:(FSTQuery *)query {
+  return [[FSTQueryListener alloc]
+            initWithQuery:query
+                  options:[FSTListenOptions defaultOptions]
+      viewSnapshotHandler:^(FSTViewSnapshot *_Nullable snapshot, NSError *_Nullable error){
+      }];
+}
+
+- (void)testHandlesManyListenersPerQuery {
+  FSTQuery *query = [FSTQuery queryWithPath:FSTTestPath(@"foo/bar")];
+  FSTQueryListener *listener1 = [self noopListenerForQuery:query];
+  FSTQueryListener *listener2 = [self noopListenerForQuery:query];
+
+  FSTSyncEngine *syncEngineMock = OCMStrictClassMock([FSTSyncEngine class]);
+  OCMExpect([syncEngineMock setDelegate:[OCMArg any]]);
+  FSTEventManager *eventManager = [FSTEventManager eventManagerWithSyncEngine:syncEngineMock];
+
+  OCMExpect([syncEngineMock listenToQuery:query]);
+  [eventManager addListener:listener1];
+  OCMVerifyAll((id)syncEngineMock);
+
+  [eventManager addListener:listener2];
+  [eventManager removeListener:listener2];
+
+  OCMExpect([syncEngineMock stopListeningToQuery:query]);
+  [eventManager removeListener:listener1];
+  OCMVerifyAll((id)syncEngineMock);
+}
+
+- (void)testHandlesUnlistenOnUnknownListenerGracefully {
+  FSTQuery *query = [FSTQuery queryWithPath:FSTTestPath(@"foo/bar")];
+  FSTQueryListener *listener = [self noopListenerForQuery:query];
+
+  FSTSyncEngine *syncEngineMock = OCMStrictClassMock([FSTSyncEngine class]);
+  OCMExpect([syncEngineMock setDelegate:[OCMArg any]]);
+  FSTEventManager *eventManager = [FSTEventManager eventManagerWithSyncEngine:syncEngineMock];
+
+  [eventManager removeListener:listener];
+  OCMVerifyAll((id)syncEngineMock);
+}
+
+- (FSTQueryListener *)makeMockListenerForQuery:(FSTQuery *)query
+                           viewSnapshotHandler:(void (^)())handler {
+  FSTQueryListener *listener = OCMClassMock([FSTQueryListener class]);
+  OCMStub([listener query]).andReturn(query);
+  OCMStub([listener queryDidChangeViewSnapshot:[OCMArg any]]).andDo(^(NSInvocation *invocation) {
+    handler();
+  });
+  return listener;
+}
+
+- (void)testNotifiesListenersInTheRightOrder {
+  FSTQuery *query1 = [FSTQuery queryWithPath:FSTTestPath(@"foo/bar")];
+  FSTQuery *query2 = [FSTQuery queryWithPath:FSTTestPath(@"bar/baz")];
+  NSMutableArray *eventOrder = [NSMutableArray array];
+
+  FSTQueryListener *listener1 = [self makeMockListenerForQuery:query1
+                                           viewSnapshotHandler:^{
+                                             [eventOrder addObject:@"listener1"];
+                                           }];
+
+  FSTQueryListener *listener2 = [self makeMockListenerForQuery:query2
+                                           viewSnapshotHandler:^{
+                                             [eventOrder addObject:@"listener2"];
+                                           }];
+
+  FSTQueryListener *listener3 = [self makeMockListenerForQuery:query1
+                                           viewSnapshotHandler:^{
+                                             [eventOrder addObject:@"listener3"];
+                                           }];
+
+  FSTSyncEngine *syncEngineMock = OCMClassMock([FSTSyncEngine class]);
+  FSTEventManager *eventManager = [FSTEventManager eventManagerWithSyncEngine:syncEngineMock];
+
+  [eventManager addListener:listener1];
+  [eventManager addListener:listener2];
+  [eventManager addListener:listener3];
+  OCMVerify([syncEngineMock listenToQuery:query1]);
+  OCMVerify([syncEngineMock listenToQuery:query2]);
+
+  FSTViewSnapshot *snapshot1 = OCMClassMock([FSTViewSnapshot class]);
+  OCMStub([snapshot1 query]).andReturn(query1);
+  FSTViewSnapshot *snapshot2 = OCMClassMock([FSTViewSnapshot class]);
+  OCMStub([snapshot2 query]).andReturn(query2);
+
+  [eventManager handleViewSnapshots:@[ snapshot1, snapshot2 ]];
+
+  NSArray *expected = @[ @"listener1", @"listener3", @"listener2" ];
+  XCTAssertEqualObjects(eventOrder, expected);
+}
+
+- (void)testWillForwardOnlineStateChanges {
+  FSTQuery *query = [FSTQuery queryWithPath:FSTTestPath(@"foo/bar")];
+  FSTQueryListener *fakeListener = OCMClassMock([FSTQueryListener class]);
+  NSMutableArray *events = [NSMutableArray array];
+  OCMStub([fakeListener query]).andReturn(query);
+  OCMStub([fakeListener clientDidChangeOnlineState:FSTOnlineStateUnknown])
+      .andDo(^(NSInvocation *invocation) {
+        [events addObject:@(FSTOnlineStateUnknown)];
+      });
+  OCMStub([fakeListener clientDidChangeOnlineState:FSTOnlineStateHealthy])
+      .andDo(^(NSInvocation *invocation) {
+        [events addObject:@(FSTOnlineStateHealthy)];
+      });
+
+  FSTSyncEngine *syncEngineMock = OCMClassMock([FSTSyncEngine class]);
+  OCMExpect([syncEngineMock setDelegate:[OCMArg any]]);
+  FSTEventManager *eventManager = [FSTEventManager eventManagerWithSyncEngine:syncEngineMock];
+
+  [eventManager addListener:fakeListener];
+  XCTAssertEqualObjects(events, @[ @(FSTOnlineStateUnknown) ]);
+  [eventManager watchStreamDidChangeOnlineState:FSTOnlineStateHealthy];
+  XCTAssertEqualObjects(events, (@[ @(FSTOnlineStateUnknown), @(FSTOnlineStateHealthy) ]));
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 487 - 0
Firestore/Example/Tests/Core/FSTQueryListenerTests.m

@@ -0,0 +1,487 @@
+/*
+ * 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 "Core/FSTEventManager.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Core/FSTQuery.h"
+#import "Core/FSTView.h"
+#import "Model/FSTDocument.h"
+#import "Model/FSTDocumentSet.h"
+#import "Remote/FSTRemoteEvent.h"
+#import "Util/FSTAsyncQueryListener.h"
+#import "Util/FSTDispatchQueue.h"
+
+#import "FSTHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTQueryListenerTests : XCTestCase
+@property(nonatomic, strong, readonly) FSTDispatchQueue *asyncQueue;
+@end
+
+@implementation FSTQueryListenerTests
+
+- (void)setUp {
+  _asyncQueue = [FSTDispatchQueue
+      queueWith:dispatch_queue_create("FSTQueryListenerTests Queue", DISPATCH_QUEUE_SERIAL)];
+}
+
+- (void)testRaisesCollectionEvents {
+  NSMutableArray<FSTViewSnapshot *> *accum = [NSMutableArray array];
+  NSMutableArray<FSTViewSnapshot *> *otherAccum = [NSMutableArray array];
+
+  FSTQuery *query = [FSTQuery queryWithPath:FSTTestPath(@"rooms")];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/Eros", 1, @{@"name" : @"Eros"}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/Hades", 2, @{@"name" : @"Hades"}, NO);
+  FSTDocument *doc2prime =
+      FSTTestDoc(@"rooms/Hades", 3, @{@"name" : @"Hades", @"owner" : @"Jonny"}, NO);
+
+  FSTQueryListener *listener = [self listenToQuery:query accumulatingSnapshots:accum];
+  FSTQueryListener *otherListener = [self listenToQuery:query accumulatingSnapshots:otherAccum];
+
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+  FSTViewSnapshot *snap1 = FSTTestApplyChanges(view, @[ doc1, doc2 ], nil);
+  FSTViewSnapshot *snap2 = FSTTestApplyChanges(view, @[ doc2prime ], nil);
+
+  FSTDocumentViewChange *change1 =
+      [FSTDocumentViewChange changeWithDocument:doc1 type:FSTDocumentViewChangeTypeAdded];
+  FSTDocumentViewChange *change2 =
+      [FSTDocumentViewChange changeWithDocument:doc2 type:FSTDocumentViewChangeTypeAdded];
+  FSTDocumentViewChange *change3 =
+      [FSTDocumentViewChange changeWithDocument:doc2prime type:FSTDocumentViewChangeTypeModified];
+  FSTDocumentViewChange *change4 =
+      [FSTDocumentViewChange changeWithDocument:doc2prime type:FSTDocumentViewChangeTypeAdded];
+
+  [listener queryDidChangeViewSnapshot:snap1];
+  [listener queryDidChangeViewSnapshot:snap2];
+  [otherListener queryDidChangeViewSnapshot:snap2];
+
+  XCTAssertEqualObjects(accum, (@[ snap1, snap2 ]));
+  XCTAssertEqualObjects(accum[0].documentChanges, (@[ change1, change2 ]));
+  XCTAssertEqualObjects(accum[1].documentChanges, (@[ change3 ]));
+
+  FSTViewSnapshot *expectedSnap2 = [[FSTViewSnapshot alloc]
+         initWithQuery:snap2.query
+             documents:snap2.documents
+          oldDocuments:[FSTDocumentSet documentSetWithComparator:snap2.query.comparator]
+       documentChanges:@[ change1, change4 ]
+             fromCache:snap2.fromCache
+      hasPendingWrites:snap2.hasPendingWrites
+      syncStateChanged:YES];
+  XCTAssertEqualObjects(otherAccum, (@[ expectedSnap2 ]));
+}
+
+- (void)testRaisesErrorEvent {
+  NSMutableArray<NSError *> *accum = [NSMutableArray array];
+  FSTQuery *query = [FSTQuery queryWithPath:FSTTestPath(@"rooms/Eros")];
+
+  FSTQueryListener *listener = [self listenToQuery:query
+                                           handler:^(FSTViewSnapshot *snapshot, NSError *error) {
+                                             [accum addObject:error];
+                                           }];
+
+  NSError *testError =
+      [NSError errorWithDomain:@"com.google.firestore.test" code:42 userInfo:@{@"some" : @"info"}];
+  [listener queryDidError:testError];
+
+  XCTAssertEqualObjects(accum, @[ testError ]);
+}
+
+- (void)testRaisesEventForEmptyCollectionAfterSync {
+  NSMutableArray<FSTViewSnapshot *> *accum = [NSMutableArray array];
+  FSTQuery *query = [FSTQuery queryWithPath:FSTTestPath(@"rooms")];
+
+  FSTQueryListener *listener = [self listenToQuery:query accumulatingSnapshots:accum];
+
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+  FSTViewSnapshot *snap1 = FSTTestApplyChanges(view, @[], nil);
+
+  FSTTargetChange *ackTarget =
+      [FSTTargetChange changeWithDocuments:@[]
+                       currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent];
+  FSTViewSnapshot *snap2 = FSTTestApplyChanges(view, @[], ackTarget);
+
+  [listener queryDidChangeViewSnapshot:snap1];
+  XCTAssertEqualObjects(accum, @[]);
+
+  [listener queryDidChangeViewSnapshot:snap2];
+  XCTAssertEqualObjects(accum, @[ snap2 ]);
+}
+
+- (void)testMutingAsyncListenerPreventsAllSubsequentEvents {
+  NSMutableArray<FSTViewSnapshot *> *accum = [NSMutableArray array];
+
+  FSTQuery *query = [FSTQuery queryWithPath:FSTTestPath(@"rooms/Eros")];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/Eros", 3, @{@"name" : @"Eros"}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/Eros", 4, @{@"name" : @"Eros2"}, NO);
+
+  __block FSTAsyncQueryListener *listener = [[FSTAsyncQueryListener alloc]
+      initWithDispatchQueue:self.asyncQueue
+            snapshotHandler:^(FSTViewSnapshot *snapshot, NSError *error) {
+              [accum addObject:snapshot];
+              [listener mute];
+            }];
+
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+  FSTViewSnapshot *viewSnapshot1 = FSTTestApplyChanges(view, @[ doc1 ], nil);
+  FSTViewSnapshot *viewSnapshot2 = FSTTestApplyChanges(view, @[ doc2 ], nil);
+
+  FSTViewSnapshotHandler handler = listener.asyncSnapshotHandler;
+  handler(viewSnapshot1, nil);
+  handler(viewSnapshot2, nil);
+
+  // Drain queue
+  XCTestExpectation *expectation = [self expectationWithDescription:@"Queue drained"];
+  [self.asyncQueue dispatchAsync:^{
+    [expectation fulfill];
+  }];
+
+  [self waitForExpectationsWithTimeout:4.0
+                               handler:^(NSError *_Nullable expectationError) {
+                                 if (expectationError) {
+                                   XCTFail(@"Error waiting for timeout: %@", expectationError);
+                                 }
+                               }];
+
+  // We should get the first snapshot but not the second.
+  XCTAssertEqualObjects(accum, @[ viewSnapshot1 ]);
+}
+
+- (void)testDoesNotRaiseEventsForMetadataChangesUnlessSpecified {
+  NSMutableArray<FSTViewSnapshot *> *filteredAccum = [NSMutableArray array];
+  NSMutableArray<FSTViewSnapshot *> *fullAccum = [NSMutableArray array];
+
+  FSTQuery *query = [FSTQuery queryWithPath:FSTTestPath(@"rooms")];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/Eros", 1, @{@"name" : @"Eros"}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/Hades", 2, @{@"name" : @"Hades"}, NO);
+
+  FSTListenOptions *options = [[FSTListenOptions alloc] initWithIncludeQueryMetadataChanges:YES
+                                                             includeDocumentMetadataChanges:NO
+                                                                      waitForSyncWhenOnline:NO];
+
+  FSTQueryListener *filteredListener =
+      [self listenToQuery:query accumulatingSnapshots:filteredAccum];
+  FSTQueryListener *fullListener =
+      [self listenToQuery:query options:options accumulatingSnapshots:fullAccum];
+
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+  FSTViewSnapshot *snap1 = FSTTestApplyChanges(view, @[ doc1 ], nil);
+
+  FSTTargetChange *ackTarget =
+      [FSTTargetChange changeWithDocuments:@[ doc1 ]
+                       currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent];
+  FSTViewSnapshot *snap2 = FSTTestApplyChanges(view, @[], ackTarget);
+  FSTViewSnapshot *snap3 = FSTTestApplyChanges(view, @[ doc2 ], nil);
+
+  [filteredListener queryDidChangeViewSnapshot:snap1];  // local event
+  [filteredListener queryDidChangeViewSnapshot:snap2];  // no event
+  [filteredListener queryDidChangeViewSnapshot:snap3];  // doc2 update
+
+  [fullListener queryDidChangeViewSnapshot:snap1];  // local event
+  [fullListener queryDidChangeViewSnapshot:snap2];  // state change event
+  [fullListener queryDidChangeViewSnapshot:snap3];  // doc2 update
+
+  XCTAssertEqualObjects(filteredAccum, (@[ snap1, snap3 ]));
+  XCTAssertEqualObjects(fullAccum, (@[ snap1, snap2, snap3 ]));
+}
+
+- (void)testRaisesDocumentMetadataEventsOnlyWhenSpecified {
+  NSMutableArray<FSTViewSnapshot *> *filteredAccum = [NSMutableArray array];
+  NSMutableArray<FSTViewSnapshot *> *fullAccum = [NSMutableArray array];
+
+  FSTQuery *query = [FSTQuery queryWithPath:FSTTestPath(@"rooms")];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/Eros", 1, @{@"name" : @"Eros"}, YES);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/Hades", 2, @{@"name" : @"Hades"}, NO);
+  FSTDocument *doc1Prime = FSTTestDoc(@"rooms/Eros", 1, @{@"name" : @"Eros"}, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/Other", 3, @{@"name" : @"Other"}, NO);
+
+  FSTListenOptions *options = [[FSTListenOptions alloc] initWithIncludeQueryMetadataChanges:NO
+                                                             includeDocumentMetadataChanges:YES
+                                                                      waitForSyncWhenOnline:NO];
+
+  FSTQueryListener *filteredListener =
+      [self listenToQuery:query accumulatingSnapshots:filteredAccum];
+  FSTQueryListener *fullListener =
+      [self listenToQuery:query options:options accumulatingSnapshots:fullAccum];
+
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+  FSTViewSnapshot *snap1 = FSTTestApplyChanges(view, @[ doc1, doc2 ], nil);
+  FSTViewSnapshot *snap2 = FSTTestApplyChanges(view, @[ doc1Prime ], nil);
+  FSTViewSnapshot *snap3 = FSTTestApplyChanges(view, @[ doc3 ], nil);
+
+  FSTDocumentViewChange *change1 =
+      [FSTDocumentViewChange changeWithDocument:doc1 type:FSTDocumentViewChangeTypeAdded];
+  FSTDocumentViewChange *change2 =
+      [FSTDocumentViewChange changeWithDocument:doc2 type:FSTDocumentViewChangeTypeAdded];
+  FSTDocumentViewChange *change3 =
+      [FSTDocumentViewChange changeWithDocument:doc1Prime type:FSTDocumentViewChangeTypeMetadata];
+  FSTDocumentViewChange *change4 =
+      [FSTDocumentViewChange changeWithDocument:doc3 type:FSTDocumentViewChangeTypeAdded];
+
+  [filteredListener queryDidChangeViewSnapshot:snap1];
+  [filteredListener queryDidChangeViewSnapshot:snap2];
+  [filteredListener queryDidChangeViewSnapshot:snap3];
+  [fullListener queryDidChangeViewSnapshot:snap1];
+  [fullListener queryDidChangeViewSnapshot:snap2];
+  [fullListener queryDidChangeViewSnapshot:snap3];
+
+  XCTAssertEqualObjects(filteredAccum, (@[ snap1, snap3 ]));
+  XCTAssertEqualObjects(filteredAccum[0].documentChanges, (@[ change1, change2 ]));
+  XCTAssertEqualObjects(filteredAccum[1].documentChanges, (@[ change4 ]));
+
+  XCTAssertEqualObjects(fullAccum, (@[ snap1, snap2, snap3 ]));
+  XCTAssertEqualObjects(fullAccum[0].documentChanges, (@[ change1, change2 ]));
+  XCTAssertEqualObjects(fullAccum[1].documentChanges, (@[ change3 ]));
+  XCTAssertEqualObjects(fullAccum[2].documentChanges, (@[ change4 ]));
+}
+
+- (void)testRaisesQueryMetadataEventsOnlyWhenHasPendingWritesOnTheQueryChanges {
+  NSMutableArray<FSTViewSnapshot *> *fullAccum = [NSMutableArray array];
+
+  FSTQuery *query = [FSTQuery queryWithPath:FSTTestPath(@"rooms")];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/Eros", 1, @{@"name" : @"Eros"}, YES);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/Hades", 2, @{@"name" : @"Hades"}, YES);
+  FSTDocument *doc1Prime = FSTTestDoc(@"rooms/Eros", 1, @{@"name" : @"Eros"}, NO);
+  FSTDocument *doc2Prime = FSTTestDoc(@"rooms/Hades", 2, @{@"name" : @"Hades"}, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/Other", 3, @{@"name" : @"Other"}, NO);
+
+  FSTListenOptions *options = [[FSTListenOptions alloc] initWithIncludeQueryMetadataChanges:YES
+                                                             includeDocumentMetadataChanges:NO
+                                                                      waitForSyncWhenOnline:NO];
+  FSTQueryListener *fullListener =
+      [self listenToQuery:query options:options accumulatingSnapshots:fullAccum];
+
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+  FSTViewSnapshot *snap1 = FSTTestApplyChanges(view, @[ doc1, doc2 ], nil);
+  FSTViewSnapshot *snap2 = FSTTestApplyChanges(view, @[ doc1Prime ], nil);
+  FSTViewSnapshot *snap3 = FSTTestApplyChanges(view, @[ doc3 ], nil);
+  FSTViewSnapshot *snap4 = FSTTestApplyChanges(view, @[ doc2Prime ], nil);
+
+  [fullListener queryDidChangeViewSnapshot:snap1];
+  [fullListener queryDidChangeViewSnapshot:snap2];  // Emits no events.
+  [fullListener queryDidChangeViewSnapshot:snap3];
+  [fullListener queryDidChangeViewSnapshot:snap4];  // Metadata change event.
+
+  FSTViewSnapshot *expectedSnap4 = [[FSTViewSnapshot alloc] initWithQuery:snap4.query
+                                                                documents:snap4.documents
+                                                             oldDocuments:snap3.documents
+                                                          documentChanges:@[]
+                                                                fromCache:snap4.fromCache
+                                                         hasPendingWrites:NO
+                                                         syncStateChanged:snap4.syncStateChanged];
+  XCTAssertEqualObjects(fullAccum, (@[ snap1, snap3, expectedSnap4 ]));
+}
+
+- (void)testMetadataOnlyDocumentChangesAreFilteredOutWhenIncludeDocumentMetadataChangesIsFalse {
+  NSMutableArray<FSTViewSnapshot *> *filteredAccum = [NSMutableArray array];
+
+  FSTQuery *query = [FSTQuery queryWithPath:FSTTestPath(@"rooms")];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/Eros", 1, @{@"name" : @"Eros"}, YES);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/Hades", 2, @{@"name" : @"Hades"}, NO);
+  FSTDocument *doc1Prime = FSTTestDoc(@"rooms/Eros", 1, @{@"name" : @"Eros"}, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/Other", 3, @{@"name" : @"Other"}, NO);
+
+  FSTQueryListener *filteredListener =
+      [self listenToQuery:query accumulatingSnapshots:filteredAccum];
+
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+  FSTViewSnapshot *snap1 = FSTTestApplyChanges(view, @[ doc1, doc2 ], nil);
+  FSTViewSnapshot *snap2 = FSTTestApplyChanges(view, @[ doc1Prime, doc3 ], nil);
+
+  FSTDocumentViewChange *change3 =
+      [FSTDocumentViewChange changeWithDocument:doc3 type:FSTDocumentViewChangeTypeAdded];
+
+  [filteredListener queryDidChangeViewSnapshot:snap1];
+  [filteredListener queryDidChangeViewSnapshot:snap2];
+
+  FSTViewSnapshot *expectedSnap2 = [[FSTViewSnapshot alloc] initWithQuery:snap2.query
+                                                                documents:snap2.documents
+                                                             oldDocuments:snap1.documents
+                                                          documentChanges:@[ change3 ]
+                                                                fromCache:snap2.isFromCache
+                                                         hasPendingWrites:snap2.hasPendingWrites
+                                                         syncStateChanged:snap2.syncStateChanged];
+  XCTAssertEqualObjects(filteredAccum, (@[ snap1, expectedSnap2 ]));
+}
+
+- (void)testWillWaitForSyncIfOnline {
+  NSMutableArray<FSTViewSnapshot *> *events = [NSMutableArray array];
+
+  FSTQuery *query = [FSTQuery queryWithPath:FSTTestPath(@"rooms")];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/Eros", 1, @{@"name" : @"Eros"}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/Hades", 2, @{@"name" : @"Hades"}, NO);
+  FSTQueryListener *listener =
+      [self listenToQuery:query
+                        options:[[FSTListenOptions alloc] initWithIncludeQueryMetadataChanges:NO
+                                                               includeDocumentMetadataChanges:NO
+                                                                        waitForSyncWhenOnline:YES]
+          accumulatingSnapshots:events];
+
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+  FSTViewSnapshot *snap1 = FSTTestApplyChanges(view, @[ doc1 ], nil);
+  FSTViewSnapshot *snap2 = FSTTestApplyChanges(view, @[ doc2 ], nil);
+  FSTViewSnapshot *snap3 =
+      FSTTestApplyChanges(view, @[],
+                          [FSTTargetChange changeWithDocuments:@[ doc1, doc2 ]
+                                           currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent]);
+
+  [listener clientDidChangeOnlineState:FSTOnlineStateHealthy];  // no event
+  [listener queryDidChangeViewSnapshot:snap1];
+  [listener clientDidChangeOnlineState:FSTOnlineStateUnknown];
+  [listener clientDidChangeOnlineState:FSTOnlineStateHealthy];
+  [listener queryDidChangeViewSnapshot:snap2];
+  [listener queryDidChangeViewSnapshot:snap3];
+
+  FSTDocumentViewChange *change1 =
+      [FSTDocumentViewChange changeWithDocument:doc1 type:FSTDocumentViewChangeTypeAdded];
+  FSTDocumentViewChange *change2 =
+      [FSTDocumentViewChange changeWithDocument:doc2 type:FSTDocumentViewChangeTypeAdded];
+  FSTViewSnapshot *expectedSnap = [[FSTViewSnapshot alloc]
+         initWithQuery:snap3.query
+             documents:snap3.documents
+          oldDocuments:[FSTDocumentSet documentSetWithComparator:snap3.query.comparator]
+       documentChanges:@[ change1, change2 ]
+             fromCache:NO
+      hasPendingWrites:NO
+      syncStateChanged:YES];
+  XCTAssertEqualObjects(events, (@[ expectedSnap ]));
+}
+
+- (void)testWillRaiseInitialEventWhenGoingOffline {
+  NSMutableArray<FSTViewSnapshot *> *events = [NSMutableArray array];
+
+  FSTQuery *query = [FSTQuery queryWithPath:FSTTestPath(@"rooms")];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/Eros", 1, @{@"name" : @"Eros"}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/Hades", 2, @{@"name" : @"Hades"}, NO);
+  FSTQueryListener *listener =
+      [self listenToQuery:query
+                        options:[[FSTListenOptions alloc] initWithIncludeQueryMetadataChanges:NO
+                                                               includeDocumentMetadataChanges:NO
+                                                                        waitForSyncWhenOnline:YES]
+          accumulatingSnapshots:events];
+
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+  FSTViewSnapshot *snap1 = FSTTestApplyChanges(view, @[ doc1 ], nil);
+  FSTViewSnapshot *snap2 = FSTTestApplyChanges(view, @[ doc2 ], nil);
+
+  [listener clientDidChangeOnlineState:FSTOnlineStateHealthy];  // no event
+  [listener queryDidChangeViewSnapshot:snap1];                  // no event
+  [listener clientDidChangeOnlineState:FSTOnlineStateFailed];   // event
+  [listener clientDidChangeOnlineState:FSTOnlineStateUnknown];  // no event
+  [listener clientDidChangeOnlineState:FSTOnlineStateFailed];   // no event
+  [listener queryDidChangeViewSnapshot:snap2];                  // another event
+
+  FSTDocumentViewChange *change1 =
+      [FSTDocumentViewChange changeWithDocument:doc1 type:FSTDocumentViewChangeTypeAdded];
+  FSTDocumentViewChange *change2 =
+      [FSTDocumentViewChange changeWithDocument:doc2 type:FSTDocumentViewChangeTypeAdded];
+  FSTViewSnapshot *expectedSnap1 = [[FSTViewSnapshot alloc]
+         initWithQuery:query
+             documents:snap1.documents
+          oldDocuments:[FSTDocumentSet documentSetWithComparator:snap1.query.comparator]
+       documentChanges:@[ change1 ]
+             fromCache:YES
+      hasPendingWrites:NO
+      syncStateChanged:YES];
+  FSTViewSnapshot *expectedSnap2 = [[FSTViewSnapshot alloc] initWithQuery:query
+                                                                documents:snap2.documents
+                                                             oldDocuments:snap1.documents
+                                                          documentChanges:@[ change2 ]
+                                                                fromCache:YES
+                                                         hasPendingWrites:NO
+                                                         syncStateChanged:NO];
+  XCTAssertEqualObjects(events, (@[ expectedSnap1, expectedSnap2 ]));
+}
+
+- (void)testWillRaiseInitialEventWhenGoingOfflineAndThereAreNoDocs {
+  NSMutableArray<FSTViewSnapshot *> *events = [NSMutableArray array];
+
+  FSTQuery *query = [FSTQuery queryWithPath:FSTTestPath(@"rooms")];
+  FSTQueryListener *listener = [self listenToQuery:query
+                                           options:[FSTListenOptions defaultOptions]
+                             accumulatingSnapshots:events];
+
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+  FSTViewSnapshot *snap1 = FSTTestApplyChanges(view, @[], nil);
+
+  [listener clientDidChangeOnlineState:FSTOnlineStateHealthy];  // no event
+  [listener queryDidChangeViewSnapshot:snap1];                  // no event
+  [listener clientDidChangeOnlineState:FSTOnlineStateFailed];   // event
+
+  FSTViewSnapshot *expectedSnap = [[FSTViewSnapshot alloc]
+         initWithQuery:query
+             documents:snap1.documents
+          oldDocuments:[FSTDocumentSet documentSetWithComparator:snap1.query.comparator]
+       documentChanges:@[]
+             fromCache:YES
+      hasPendingWrites:NO
+      syncStateChanged:YES];
+  XCTAssertEqualObjects(events, (@[ expectedSnap ]));
+}
+
+- (void)testWillRaiseInitialEventWhenStartingOfflineAndThereAreNoDocs {
+  NSMutableArray<FSTViewSnapshot *> *events = [NSMutableArray array];
+
+  FSTQuery *query = [FSTQuery queryWithPath:FSTTestPath(@"rooms")];
+  FSTQueryListener *listener = [self listenToQuery:query
+                                           options:[FSTListenOptions defaultOptions]
+                             accumulatingSnapshots:events];
+
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+  FSTViewSnapshot *snap1 = FSTTestApplyChanges(view, @[], nil);
+
+  [listener clientDidChangeOnlineState:FSTOnlineStateFailed];  // no event
+  [listener queryDidChangeViewSnapshot:snap1];                 // event
+
+  FSTViewSnapshot *expectedSnap = [[FSTViewSnapshot alloc]
+         initWithQuery:query
+             documents:snap1.documents
+          oldDocuments:[FSTDocumentSet documentSetWithComparator:snap1.query.comparator]
+       documentChanges:@[]
+             fromCache:YES
+      hasPendingWrites:NO
+      syncStateChanged:YES];
+  XCTAssertEqualObjects(events, (@[ expectedSnap ]));
+}
+
+- (FSTQueryListener *)listenToQuery:(FSTQuery *)query handler:(FSTViewSnapshotHandler)handler {
+  return [[FSTQueryListener alloc] initWithQuery:query
+                                         options:[FSTListenOptions defaultOptions]
+                             viewSnapshotHandler:handler];
+}
+
+- (FSTQueryListener *)listenToQuery:(FSTQuery *)query
+                            options:(FSTListenOptions *)options
+              accumulatingSnapshots:(NSMutableArray<FSTViewSnapshot *> *)values {
+  return [[FSTQueryListener alloc] initWithQuery:query
+                                         options:options
+                             viewSnapshotHandler:^(FSTViewSnapshot *snapshot, NSError *error) {
+                               [values addObject:snapshot];
+                             }];
+}
+
+- (FSTQueryListener *)listenToQuery:(FSTQuery *)query
+              accumulatingSnapshots:(NSMutableArray<FSTViewSnapshot *> *)values {
+  return [self listenToQuery:query
+                     options:[FSTListenOptions defaultOptions]
+       accumulatingSnapshots:values];
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 577 - 0
Firestore/Example/Tests/Core/FSTQueryTests.m

@@ -0,0 +1,577 @@
+/*
+ * 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 "Core/FSTQuery.h"
+
+#import <XCTest/XCTest.h>
+
+#import "API/FIRFirestore+Internal.h"
+#import "Model/FSTDatabaseID.h"
+#import "Model/FSTDocument.h"
+#import "Model/FSTDocumentKey.h"
+#import "Model/FSTPath.h"
+
+#import "FSTHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** Convenience methods for building test queries. */
+@interface FSTQuery (Tests)
+- (FSTQuery *)queryByAddingSortBy:(NSString *)key ascending:(BOOL)ascending;
+@end
+
+@implementation FSTQuery (Tests)
+
+- (FSTQuery *)queryByAddingSortBy:(NSString *)key ascending:(BOOL)ascending {
+  return [self queryByAddingSortOrder:[FSTSortOrder sortOrderWithFieldPath:FSTTestFieldPath(key)
+                                                                 ascending:ascending]];
+}
+
+@end
+
+@interface FSTQueryTests : XCTestCase
+@end
+
+@implementation FSTQueryTests
+
+- (void)testConstructor {
+  FSTResourcePath *path =
+      [FSTResourcePath pathWithSegments:@[ @"rooms", @"Firestore", @"messages", @"0001" ]];
+  FSTQuery *query = [FSTQuery queryWithPath:path];
+  XCTAssertNotNil(query);
+
+  XCTAssertEqual(query.sortOrders.count, 1);
+  XCTAssertEqualObjects(query.sortOrders[0].field.canonicalString, kDocumentKeyPath);
+  XCTAssertEqual(query.sortOrders[0].ascending, YES);
+
+  XCTAssertEqual(query.explicitSortOrders.count, 0);
+}
+
+- (void)testOrderBy {
+  FSTResourcePath *path =
+      [FSTResourcePath pathWithSegments:@[ @"rooms", @"Firestore", @"messages" ]];
+  FSTQuery *query = [FSTQuery queryWithPath:path];
+  query =
+      [query queryByAddingSortOrder:[FSTSortOrder sortOrderWithFieldPath:FSTTestFieldPath(@"length")
+                                                               ascending:NO]];
+
+  XCTAssertEqual(query.sortOrders.count, 2);
+  XCTAssertEqualObjects(query.sortOrders[0].field.canonicalString, @"length");
+  XCTAssertEqual(query.sortOrders[0].ascending, NO);
+  XCTAssertEqualObjects(query.sortOrders[1].field.canonicalString, kDocumentKeyPath);
+  XCTAssertEqual(query.sortOrders[1].ascending, NO);
+
+  XCTAssertEqual(query.explicitSortOrders.count, 1);
+  XCTAssertEqualObjects(query.explicitSortOrders[0].field.canonicalString, @"length");
+  XCTAssertEqual(query.explicitSortOrders[0].ascending, NO);
+}
+
+- (void)testMatchesBasedOnDocumentKey {
+  FSTResourcePath *queryKey =
+      [FSTResourcePath pathWithSegments:@[ @"rooms", @"eros", @"messages", @"1" ]];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{@"text" : @"msg1"}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/2", 0, @{@"text" : @"msg2"}, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/other/messages/1", 0, @{@"text" : @"msg3"}, NO);
+
+  // document query
+  FSTQuery *query = [FSTQuery queryWithPath:queryKey];
+  XCTAssertTrue([query matchesDocument:doc1]);
+  XCTAssertFalse([query matchesDocument:doc2]);
+  XCTAssertFalse([query matchesDocument:doc3]);
+}
+
+- (void)testMatchesCorrectlyForShallowAncestorQuery {
+  FSTResourcePath *queryPath =
+      [FSTResourcePath pathWithSegments:@[ @"rooms", @"eros", @"messages" ]];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{@"text" : @"msg1"}, NO);
+  FSTDocument *doc1Meta = FSTTestDoc(@"rooms/eros/messages/1/meta/1", 0, @{@"meta" : @"mv"}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/2", 0, @{@"text" : @"msg2"}, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/other/messages/1", 0, @{@"text" : @"msg3"}, NO);
+
+  // shallow ancestor query
+  FSTQuery *query = [FSTQuery queryWithPath:queryPath];
+  XCTAssertTrue([query matchesDocument:doc1]);
+  XCTAssertFalse([query matchesDocument:doc1Meta]);
+  XCTAssertTrue([query matchesDocument:doc2]);
+  XCTAssertFalse([query matchesDocument:doc3]);
+}
+
+- (void)testEmptyFieldsAreAllowedForQueries {
+  FSTResourcePath *queryPath = [FSTResourcePath pathWithString:@"rooms/eros/messages"];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{@"text" : @"msg1"}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/2", 0, @{}, NO);
+
+  FSTQuery *query = [[FSTQuery queryWithPath:queryPath]
+      queryByAddingFilter:FSTTestFilter(@"text", @"==", @"msg1")];
+  XCTAssertTrue([query matchesDocument:doc1]);
+  XCTAssertFalse([query matchesDocument:doc2]);
+}
+
+- (void)testMatchesPrimitiveValuesForFilters {
+  FSTQuery *query1 = [[FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"collection" ]]]
+      queryByAddingFilter:FSTTestFilter(@"sort", @">=", @(2))];
+  FSTQuery *query2 = [[FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"collection" ]]]
+      queryByAddingFilter:FSTTestFilter(@"sort", @"<=", @(2))];
+
+  FSTDocument *doc1 = FSTTestDoc(@"collection/1", 0, @{ @"sort" : @1 }, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"collection/2", 0, @{ @"sort" : @2 }, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"collection/3", 0, @{ @"sort" : @3 }, NO);
+  FSTDocument *doc4 = FSTTestDoc(@"collection/4", 0, @{ @"sort" : @NO }, NO);
+  FSTDocument *doc5 = FSTTestDoc(@"collection/5", 0, @{@"sort" : @"string"}, NO);
+  FSTDocument *doc6 = FSTTestDoc(@"collection/6", 0, @{}, NO);
+
+  XCTAssertFalse([query1 matchesDocument:doc1]);
+  XCTAssertTrue([query1 matchesDocument:doc2]);
+  XCTAssertTrue([query1 matchesDocument:doc3]);
+  XCTAssertFalse([query1 matchesDocument:doc4]);
+  XCTAssertFalse([query1 matchesDocument:doc5]);
+  XCTAssertFalse([query1 matchesDocument:doc6]);
+
+  XCTAssertTrue([query2 matchesDocument:doc1]);
+  XCTAssertTrue([query2 matchesDocument:doc2]);
+  XCTAssertFalse([query2 matchesDocument:doc3]);
+  XCTAssertFalse([query2 matchesDocument:doc4]);
+  XCTAssertFalse([query2 matchesDocument:doc5]);
+  XCTAssertFalse([query2 matchesDocument:doc6]);
+}
+
+- (void)testNullFilter {
+  FSTQuery *query = [[FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"collection" ]]]
+      queryByAddingFilter:FSTTestFilter(@"sort", @"==", [NSNull null])];
+  FSTDocument *doc1 = FSTTestDoc(@"collection/1", 0, @{@"sort" : [NSNull null]}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"collection/2", 0, @{ @"sort" : @2 }, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"collection/2", 0, @{ @"sort" : @3.1 }, NO);
+  FSTDocument *doc4 = FSTTestDoc(@"collection/4", 0, @{ @"sort" : @NO }, NO);
+  FSTDocument *doc5 = FSTTestDoc(@"collection/5", 0, @{@"sort" : @"string"}, NO);
+
+  XCTAssertTrue([query matchesDocument:doc1]);
+  XCTAssertFalse([query matchesDocument:doc2]);
+  XCTAssertFalse([query matchesDocument:doc3]);
+  XCTAssertFalse([query matchesDocument:doc4]);
+  XCTAssertFalse([query matchesDocument:doc5]);
+}
+
+- (void)testNanFilter {
+  FSTQuery *query = [[FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"collection" ]]]
+      queryByAddingFilter:FSTTestFilter(@"sort", @"==", @(NAN))];
+  FSTDocument *doc1 = FSTTestDoc(@"collection/1", 0, @{ @"sort" : @(NAN) }, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"collection/2", 0, @{ @"sort" : @2 }, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"collection/2", 0, @{ @"sort" : @3.1 }, NO);
+  FSTDocument *doc4 = FSTTestDoc(@"collection/4", 0, @{ @"sort" : @NO }, NO);
+  FSTDocument *doc5 = FSTTestDoc(@"collection/5", 0, @{@"sort" : @"string"}, NO);
+
+  XCTAssertTrue([query matchesDocument:doc1]);
+  XCTAssertFalse([query matchesDocument:doc2]);
+  XCTAssertFalse([query matchesDocument:doc3]);
+  XCTAssertFalse([query matchesDocument:doc4]);
+  XCTAssertFalse([query matchesDocument:doc5]);
+}
+
+- (void)testDoesNotMatchComplexObjectsForFilters {
+  FSTQuery *query1 = [[FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"collection" ]]]
+      queryByAddingFilter:FSTTestFilter(@"sort", @"<=", @(2))];
+  FSTQuery *query2 = [[FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"collection" ]]]
+      queryByAddingFilter:FSTTestFilter(@"sort", @">=", @(2))];
+
+  FSTDocument *doc1 = FSTTestDoc(@"collection/1", 0, @{ @"sort" : @2 }, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"collection/2", 0, @{ @"sort" : @[] }, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"collection/3", 0, @{ @"sort" : @[ @1 ] }, NO);
+  FSTDocument *doc4 = FSTTestDoc(@"collection/4", 0, @{ @"sort" : @{@"foo" : @2} }, NO);
+  FSTDocument *doc5 = FSTTestDoc(@"collection/5", 0, @{ @"sort" : @{@"foo" : @"bar"} }, NO);
+  FSTDocument *doc6 = FSTTestDoc(@"collection/6", 0, @{ @"sort" : @{} }, NO);  // no sort field
+  FSTDocument *doc7 = FSTTestDoc(@"collection/7", 0, @{ @"sort" : @[ @3, @1 ] }, NO);
+
+  XCTAssertTrue([query1 matchesDocument:doc1]);
+  XCTAssertFalse([query1 matchesDocument:doc2]);
+  XCTAssertFalse([query1 matchesDocument:doc3]);
+  XCTAssertFalse([query1 matchesDocument:doc4]);
+  XCTAssertFalse([query1 matchesDocument:doc5]);
+  XCTAssertFalse([query1 matchesDocument:doc6]);
+  XCTAssertFalse([query1 matchesDocument:doc7]);
+
+  XCTAssertTrue([query2 matchesDocument:doc1]);
+  XCTAssertFalse([query2 matchesDocument:doc2]);
+  XCTAssertFalse([query2 matchesDocument:doc3]);
+  XCTAssertFalse([query2 matchesDocument:doc4]);
+  XCTAssertFalse([query2 matchesDocument:doc5]);
+  XCTAssertFalse([query2 matchesDocument:doc6]);
+  XCTAssertFalse([query2 matchesDocument:doc7]);
+}
+
+- (void)testDoesntRemoveComplexObjectsWithOrderBy {
+  FSTQuery *query1 = [[FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"collection" ]]]
+      queryByAddingSortOrder:[FSTSortOrder sortOrderWithFieldPath:FSTTestFieldPath(@"sort")
+                                                        ascending:YES]];
+
+  FSTDocument *doc1 = FSTTestDoc(@"collection/1", 0, @{ @"sort" : @2 }, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"collection/2", 0, @{ @"sort" : @[] }, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"collection/3", 0, @{ @"sort" : @[ @1 ] }, NO);
+  FSTDocument *doc4 = FSTTestDoc(@"collection/4", 0, @{ @"sort" : @{@"foo" : @2} }, NO);
+  FSTDocument *doc5 = FSTTestDoc(@"collection/5", 0, @{ @"sort" : @{@"foo" : @"bar"} }, NO);
+  FSTDocument *doc6 = FSTTestDoc(@"collection/6", 0, @{}, NO);
+
+  XCTAssertTrue([query1 matchesDocument:doc1]);
+  XCTAssertTrue([query1 matchesDocument:doc2]);
+  XCTAssertTrue([query1 matchesDocument:doc3]);
+  XCTAssertTrue([query1 matchesDocument:doc4]);
+  XCTAssertTrue([query1 matchesDocument:doc5]);
+  XCTAssertFalse([query1 matchesDocument:doc6]);
+}
+
+- (void)testFiltersBasedOnArrayValue {
+  FSTQuery *baseQuery =
+      [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"collection" ]]];
+
+  FSTDocument *doc1 = FSTTestDoc(@"collection/doc", 0, @{ @"tags" : @[ @"foo", @1, @YES ] }, NO);
+
+  NSArray<id<FSTFilter>> *matchingFilters =
+      @[ FSTTestFilter(@"tags", @"==", @[ @"foo", @1, @YES ]) ];
+
+  NSArray<id<FSTFilter>> *nonMatchingFilters = @[
+    FSTTestFilter(@"tags", @"==", @"foo"),
+    FSTTestFilter(@"tags", @"==", @[ @"foo", @1 ]),
+    FSTTestFilter(@"tags", @"==", @[ @"foo", @YES, @1 ]),
+  ];
+
+  for (id<FSTFilter> filter in matchingFilters) {
+    XCTAssertTrue([[baseQuery queryByAddingFilter:filter] matchesDocument:doc1]);
+  }
+
+  for (id<FSTFilter> filter in nonMatchingFilters) {
+    XCTAssertFalse([[baseQuery queryByAddingFilter:filter] matchesDocument:doc1]);
+  }
+}
+
+- (void)testFiltersBasedOnObjectValue {
+  FSTQuery *baseQuery =
+      [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"collection" ]]];
+
+  FSTDocument *doc1 =
+      FSTTestDoc(@"collection/doc", 0,
+                 @{ @"tags" : @{@"foo" : @"foo", @"a" : @0, @"b" : @YES, @"c" : @(NAN)} }, NO);
+
+  NSArray<id<FSTFilter>> *matchingFilters = @[
+    FSTTestFilter(@"tags", @"==",
+                  @{ @"foo" : @"foo",
+                     @"a" : @0,
+                     @"b" : @YES,
+                     @"c" : @(NAN) }),
+    FSTTestFilter(@"tags", @"==",
+                  @{ @"b" : @YES,
+                     @"a" : @0,
+                     @"foo" : @"foo",
+                     @"c" : @(NAN) }),
+    FSTTestFilter(@"tags.foo", @"==", @"foo")
+  ];
+
+  NSArray<id<FSTFilter>> *nonMatchingFilters = @[
+    FSTTestFilter(@"tags", @"==", @"foo"), FSTTestFilter(@"tags", @"==", @{
+      @"foo" : @"foo",
+      @"a" : @0,
+      @"b" : @YES,
+    })
+  ];
+
+  for (id<FSTFilter> filter in matchingFilters) {
+    XCTAssertTrue([[baseQuery queryByAddingFilter:filter] matchesDocument:doc1]);
+  }
+
+  for (id<FSTFilter> filter in nonMatchingFilters) {
+    XCTAssertFalse([[baseQuery queryByAddingFilter:filter] matchesDocument:doc1]);
+  }
+}
+
+/**
+ * Checks that an ordered array of elements yields the correct pair-wise comparison result for the
+ * supplied comparator.
+ */
+- (void)assertCorrectComparisonsWithArray:(NSArray *)array comparator:(NSComparator)comp {
+  [array enumerateObjectsUsingBlock:^(id iObj, NSUInteger i, BOOL *outerStop) {
+    [array enumerateObjectsUsingBlock:^(id _Nonnull jObj, NSUInteger j, BOOL *innerStop) {
+      NSComparisonResult expected = [@(i) compare:@(j)];
+      NSComparisonResult actual = comp(iObj, jObj);
+      XCTAssertEqual(actual, expected, @"Compared %@ to %@ at (%lu, %lu).", iObj, jObj,
+                     (unsigned long)i, (unsigned long)j);
+    }];
+  }];
+}
+
+- (void)testSortsDocumentsInTheCorrectOrder {
+  FSTQuery *query = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"collection" ]]];
+  query =
+      [query queryByAddingSortOrder:[FSTSortOrder sortOrderWithFieldPath:FSTTestFieldPath(@"sort")
+                                                               ascending:YES]];
+
+  // clang-format off
+  NSArray<FSTDocument *> *docs = @[
+      FSTTestDoc(@"collection/1", 0, @{@"sort": [NSNull null]}, NO),
+      FSTTestDoc(@"collection/1", 0, @{@"sort": @NO}, NO),
+      FSTTestDoc(@"collection/1", 0, @{@"sort": @YES}, NO),
+      FSTTestDoc(@"collection/1", 0, @{@"sort": @1}, NO),
+      FSTTestDoc(@"collection/2", 0, @{@"sort": @1}, NO),  // by key
+      FSTTestDoc(@"collection/3", 0, @{@"sort": @1}, NO),  // by key
+      FSTTestDoc(@"collection/1", 0, @{@"sort": @1.9}, NO),
+      FSTTestDoc(@"collection/1", 0, @{@"sort": @2}, NO),
+      FSTTestDoc(@"collection/1", 0, @{@"sort": @2.1}, NO),
+      FSTTestDoc(@"collection/1", 0, @{@"sort": @""}, NO),
+      FSTTestDoc(@"collection/1", 0, @{@"sort": @"a"}, NO),
+      FSTTestDoc(@"collection/1", 0, @{@"sort": @"ab"}, NO),
+      FSTTestDoc(@"collection/1", 0, @{@"sort": @"b"}, NO),
+      FSTTestDoc(@"collection/1", 0, @{@"sort":
+          FSTTestRef(@"project", kDefaultDatabaseID, @"collection/id1")}, NO),
+  ];
+  // clang-format on
+
+  [self assertCorrectComparisonsWithArray:docs comparator:query.comparator];
+}
+
+- (void)testSortsDocumentsUsingMultipleFields {
+  FSTQuery *query = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"collection" ]]];
+  query =
+      [query queryByAddingSortOrder:[FSTSortOrder sortOrderWithFieldPath:FSTTestFieldPath(@"sort1")
+                                                               ascending:YES]];
+  query =
+      [query queryByAddingSortOrder:[FSTSortOrder sortOrderWithFieldPath:FSTTestFieldPath(@"sort2")
+                                                               ascending:YES]];
+
+  // clang-format off
+  NSArray<FSTDocument *> *docs =
+      @[FSTTestDoc(@"collection/1", 0, @{@"sort1": @1, @"sort2": @1}, NO),
+        FSTTestDoc(@"collection/1", 0, @{@"sort1": @1, @"sort2": @2}, NO),
+        FSTTestDoc(@"collection/2", 0, @{@"sort1": @1, @"sort2": @2}, NO),  // by key
+        FSTTestDoc(@"collection/3", 0, @{@"sort1": @1, @"sort2": @2}, NO),  // by key
+        FSTTestDoc(@"collection/1", 0, @{@"sort1": @1, @"sort2": @3}, NO),
+        FSTTestDoc(@"collection/1", 0, @{@"sort1": @2, @"sort2": @1}, NO),
+        FSTTestDoc(@"collection/1", 0, @{@"sort1": @2, @"sort2": @2}, NO),
+        FSTTestDoc(@"collection/2", 0, @{@"sort1": @2, @"sort2": @2}, NO),  // by key
+        FSTTestDoc(@"collection/3", 0, @{@"sort1": @2, @"sort2": @2}, NO),  // by key
+        FSTTestDoc(@"collection/1", 0, @{@"sort1": @2, @"sort2": @3}, NO),
+        ];
+  // clang-format on
+
+  [self assertCorrectComparisonsWithArray:docs comparator:query.comparator];
+}
+
+- (void)testSortsDocumentsWithDescendingToo {
+  FSTQuery *query = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"collection" ]]];
+  query =
+      [query queryByAddingSortOrder:[FSTSortOrder sortOrderWithFieldPath:FSTTestFieldPath(@"sort1")
+                                                               ascending:NO]];
+  query =
+      [query queryByAddingSortOrder:[FSTSortOrder sortOrderWithFieldPath:FSTTestFieldPath(@"sort2")
+                                                               ascending:NO]];
+
+  // clang-format off
+  NSArray<FSTDocument *> *docs =
+      @[FSTTestDoc(@"collection/1", 0, @{@"sort1": @2, @"sort2": @3}, NO),
+        FSTTestDoc(@"collection/3", 0, @{@"sort1": @2, @"sort2": @2}, NO),
+        FSTTestDoc(@"collection/2", 0, @{@"sort1": @2, @"sort2": @2}, NO),  // by key
+        FSTTestDoc(@"collection/1", 0, @{@"sort1": @2, @"sort2": @2}, NO),  // by key
+        FSTTestDoc(@"collection/1", 0, @{@"sort1": @2, @"sort2": @1}, NO),
+        FSTTestDoc(@"collection/1", 0, @{@"sort1": @1, @"sort2": @3}, NO),
+        FSTTestDoc(@"collection/3", 0, @{@"sort1": @1, @"sort2": @2}, NO),
+        FSTTestDoc(@"collection/2", 0, @{@"sort1": @1, @"sort2": @2}, NO),  // by key
+        FSTTestDoc(@"collection/1", 0, @{@"sort1": @1, @"sort2": @2}, NO),  // by key
+        FSTTestDoc(@"collection/1", 0, @{@"sort1": @1, @"sort2": @1}, NO),
+        ];
+  // clang-format on
+
+  [self assertCorrectComparisonsWithArray:docs comparator:query.comparator];
+}
+
+- (void)testEquality {
+  FSTQuery *q11 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q11 = [q11 queryByAddingFilter:FSTTestFilter(@"i1", @"<", @(2))];
+  q11 = [q11 queryByAddingFilter:FSTTestFilter(@"i2", @"==", @(3))];
+  FSTQuery *q12 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q12 = [q12 queryByAddingFilter:FSTTestFilter(@"i2", @"==", @(3))];
+  q12 = [q12 queryByAddingFilter:FSTTestFilter(@"i1", @"<", @(2))];
+
+  FSTQuery *q21 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  FSTQuery *q22 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+
+  FSTQuery *q31 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo", @"bar" ]]];
+  FSTQuery *q32 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo", @"bar" ]]];
+
+  FSTQuery *q41 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q41 = [q41 queryByAddingSortBy:@"foo" ascending:YES];
+  q41 = [q41 queryByAddingSortBy:@"bar" ascending:YES];
+  FSTQuery *q42 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q42 = [q42 queryByAddingSortBy:@"foo" ascending:YES];
+  q42 = [q42 queryByAddingSortBy:@"bar" ascending:YES];
+  FSTQuery *q43Diff = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q43Diff = [q43Diff queryByAddingSortBy:@"bar" ascending:YES];
+  q43Diff = [q43Diff queryByAddingSortBy:@"foo" ascending:YES];
+
+  FSTQuery *q51 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q51 = [q51 queryByAddingSortBy:@"foo" ascending:YES];
+  q51 = [q51 queryByAddingFilter:FSTTestFilter(@"foo", @">", @(2))];
+  FSTQuery *q52 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q52 = [q52 queryByAddingFilter:FSTTestFilter(@"foo", @">", @(2))];
+  q52 = [q52 queryByAddingSortBy:@"foo" ascending:YES];
+  FSTQuery *q53Diff = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q53Diff = [q53Diff queryByAddingFilter:FSTTestFilter(@"bar", @">", @(2))];
+  q53Diff = [q53Diff queryByAddingSortBy:@"bar" ascending:YES];
+
+  FSTQuery *q61 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q61 = [q61 queryBySettingLimit:10];
+
+  // XCTAssertEqualObjects(q11, q12);  // TODO(klimt): not canonical yet
+  XCTAssertNotEqualObjects(q11, q21);
+  XCTAssertNotEqualObjects(q11, q31);
+  XCTAssertNotEqualObjects(q11, q41);
+  XCTAssertNotEqualObjects(q11, q51);
+  XCTAssertNotEqualObjects(q11, q61);
+
+  XCTAssertEqualObjects(q21, q22);
+  XCTAssertNotEqualObjects(q21, q31);
+  XCTAssertNotEqualObjects(q21, q41);
+  XCTAssertNotEqualObjects(q21, q51);
+  XCTAssertNotEqualObjects(q21, q61);
+
+  XCTAssertEqualObjects(q31, q32);
+  XCTAssertNotEqualObjects(q31, q41);
+  XCTAssertNotEqualObjects(q31, q51);
+  XCTAssertNotEqualObjects(q31, q61);
+
+  XCTAssertEqualObjects(q41, q42);
+  XCTAssertNotEqualObjects(q41, q43Diff);
+  XCTAssertNotEqualObjects(q41, q51);
+  XCTAssertNotEqualObjects(q41, q61);
+
+  XCTAssertEqualObjects(q51, q52);
+  XCTAssertNotEqualObjects(q51, q53Diff);
+  XCTAssertNotEqualObjects(q51, q61);
+}
+
+- (void)testUniqueIds {
+  FSTQuery *q11 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q11 = [q11 queryByAddingFilter:FSTTestFilter(@"i1", @"<", @(2))];
+  q11 = [q11 queryByAddingFilter:FSTTestFilter(@"i2", @"==", @(3))];
+  FSTQuery *q12 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q12 = [q12 queryByAddingFilter:FSTTestFilter(@"i2", @"==", @(3))];
+  q12 = [q12 queryByAddingFilter:FSTTestFilter(@"i1", @"<", @(2))];
+
+  FSTQuery *q21 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  FSTQuery *q22 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+
+  FSTQuery *q31 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo", @"bar" ]]];
+  FSTQuery *q32 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo", @"bar" ]]];
+
+  FSTQuery *q41 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q41 = [q41 queryByAddingSortBy:@"foo" ascending:YES];
+  q41 = [q41 queryByAddingSortBy:@"bar" ascending:YES];
+  FSTQuery *q42 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q42 = [q42 queryByAddingSortBy:@"foo" ascending:YES];
+  q42 = [q42 queryByAddingSortBy:@"bar" ascending:YES];
+  FSTQuery *q43Diff = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q43Diff = [q43Diff queryByAddingSortBy:@"bar" ascending:YES];
+  q43Diff = [q43Diff queryByAddingSortBy:@"foo" ascending:YES];
+
+  FSTQuery *q51 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q51 = [q51 queryByAddingSortBy:@"foo" ascending:YES];
+  q51 = [q51 queryByAddingFilter:FSTTestFilter(@"foo", @">", @(2))];
+  FSTQuery *q52 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q52 = [q52 queryByAddingFilter:FSTTestFilter(@"foo", @">", @(2))];
+  q52 = [q52 queryByAddingSortBy:@"foo" ascending:YES];
+  FSTQuery *q53Diff = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q53Diff = [q53Diff queryByAddingFilter:FSTTestFilter(@"bar", @">", @(2))];
+  q53Diff = [q53Diff queryByAddingSortBy:@"bar" ascending:YES];
+
+  FSTQuery *q61 = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  q61 = [q61 queryBySettingLimit:10];
+
+  // XCTAssertEqual(q11.hash, q12.hash);  // TODO(klimt): not canonical yet
+  XCTAssertNotEqual(q11.hash, q21.hash);
+  XCTAssertNotEqual(q11.hash, q31.hash);
+  XCTAssertNotEqual(q11.hash, q41.hash);
+  XCTAssertNotEqual(q11.hash, q51.hash);
+  XCTAssertNotEqual(q11.hash, q61.hash);
+
+  XCTAssertEqual(q21.hash, q22.hash);
+  XCTAssertNotEqual(q21.hash, q31.hash);
+  XCTAssertNotEqual(q21.hash, q41.hash);
+  XCTAssertNotEqual(q21.hash, q51.hash);
+  XCTAssertNotEqual(q21.hash, q61.hash);
+
+  XCTAssertEqual(q31.hash, q32.hash);
+  XCTAssertNotEqual(q31.hash, q41.hash);
+  XCTAssertNotEqual(q31.hash, q51.hash);
+  XCTAssertNotEqual(q31.hash, q61.hash);
+
+  XCTAssertEqual(q41.hash, q42.hash);
+  XCTAssertNotEqual(q41.hash, q43Diff.hash);
+  XCTAssertNotEqual(q41.hash, q51.hash);
+  XCTAssertNotEqual(q41.hash, q61.hash);
+
+  XCTAssertEqual(q51.hash, q52.hash);
+  XCTAssertNotEqual(q51.hash, q53Diff.hash);
+  XCTAssertNotEqual(q51.hash, q61.hash);
+}
+
+- (void)testImplicitOrderBy {
+  FSTQuery *baseQuery = FSTTestQuery(@"foo");
+  // Default is ascending
+  XCTAssertEqualObjects(baseQuery.sortOrders, @[ FSTTestOrderBy(kDocumentKeyPath, @"asc") ]);
+
+  // Explicit key ordering is respected
+  XCTAssertEqualObjects(
+      [baseQuery queryByAddingSortOrder:FSTTestOrderBy(kDocumentKeyPath, @"asc")].sortOrders,
+      @[ FSTTestOrderBy(kDocumentKeyPath, @"asc") ]);
+  XCTAssertEqualObjects(
+      [baseQuery queryByAddingSortOrder:FSTTestOrderBy(kDocumentKeyPath, @"desc")].sortOrders,
+      @[ FSTTestOrderBy(kDocumentKeyPath, @"desc") ]);
+
+  XCTAssertEqualObjects(
+      [[baseQuery queryByAddingSortOrder:FSTTestOrderBy(@"foo", @"asc")]
+          queryByAddingSortOrder:FSTTestOrderBy(kDocumentKeyPath, @"asc")]
+          .sortOrders,
+      (@[ FSTTestOrderBy(@"foo", @"asc"), FSTTestOrderBy(kDocumentKeyPath, @"asc") ]));
+
+  XCTAssertEqualObjects(
+      [[baseQuery queryByAddingSortOrder:FSTTestOrderBy(@"foo", @"asc")]
+          queryByAddingSortOrder:FSTTestOrderBy(kDocumentKeyPath, @"desc")]
+          .sortOrders,
+      (@[ FSTTestOrderBy(@"foo", @"asc"), FSTTestOrderBy(kDocumentKeyPath, @"desc") ]));
+
+  // Inequality filters add order bys
+  XCTAssertEqualObjects(
+      [baseQuery queryByAddingFilter:FSTTestFilter(@"foo", @"<", @5)].sortOrders,
+      (@[ FSTTestOrderBy(@"foo", @"asc"), FSTTestOrderBy(kDocumentKeyPath, @"asc") ]));
+
+  // Descending order by applies to implicit key ordering
+  XCTAssertEqualObjects(
+      [baseQuery queryByAddingSortOrder:FSTTestOrderBy(@"foo", @"desc")].sortOrders,
+      (@[ FSTTestOrderBy(@"foo", @"desc"), FSTTestOrderBy(kDocumentKeyPath, @"desc") ]));
+  XCTAssertEqualObjects([[baseQuery queryByAddingSortOrder:FSTTestOrderBy(@"foo", @"asc")]
+                            queryByAddingSortOrder:FSTTestOrderBy(@"bar", @"desc")]
+                            .sortOrders,
+                        (@[
+                          FSTTestOrderBy(@"foo", @"asc"), FSTTestOrderBy(@"bar", @"desc"),
+                          FSTTestOrderBy(kDocumentKeyPath, @"desc")
+                        ]));
+  XCTAssertEqualObjects([[baseQuery queryByAddingSortOrder:FSTTestOrderBy(@"foo", @"desc")]
+                            queryByAddingSortOrder:FSTTestOrderBy(@"bar", @"asc")]
+                            .sortOrders,
+                        (@[
+                          FSTTestOrderBy(@"foo", @"desc"), FSTTestOrderBy(@"bar", @"asc"),
+                          FSTTestOrderBy(kDocumentKeyPath, @"asc")
+                        ]));
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 32 - 0
Firestore/Example/Tests/Core/FSTSyncEngine+Testing.h

@@ -0,0 +1,32 @@
+/*
+ * 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/FSTSyncEngine.h"
+
+@class FSTDocumentKey;
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTSyncEngine (Testing)
+
+/** Returns the current set of limbo document keys and their associated target IDs. */
+- (NSDictionary<FSTDocumentKey *, FSTBoxedTargetID *> *)currentLimboDocuments;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 94 - 0
Firestore/Example/Tests/Core/FSTTargetIDGeneratorTests.m

@@ -0,0 +1,94 @@
+/*
+ * 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 "Core/FSTTargetIDGenerator.h"
+
+#import <XCTest/XCTest.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTTargetIDGenerator ()
+- (instancetype)initWithGeneratorID:(NSInteger)generatorID startingAfterID:(FSTTargetID)after;
+@end
+
+@interface FSTTargetIDGeneratorTests : XCTestCase
+@end
+
+@implementation FSTTargetIDGeneratorTests
+
+- (void)testConstructor {
+  XCTAssertEqual([[[FSTTargetIDGenerator alloc] initWithGeneratorID:0 startingAfterID:0] nextID],
+                 2);
+  XCTAssertEqual([[[FSTTargetIDGenerator alloc] initWithGeneratorID:1 startingAfterID:0] nextID],
+                 1);
+
+  XCTAssertEqual([[FSTTargetIDGenerator generatorForLocalStoreStartingAfterID:0] nextID], 2);
+  XCTAssertEqual([[FSTTargetIDGenerator generatorForSyncEngineStartingAfterID:0] nextID], 1);
+}
+
+- (void)testSkipPast {
+  FSTTargetIDGenerator *gen =
+      [[FSTTargetIDGenerator alloc] initWithGeneratorID:1 startingAfterID:-1];
+  XCTAssertEqual([gen nextID], 1);
+
+  gen = [[FSTTargetIDGenerator alloc] initWithGeneratorID:1 startingAfterID:2];
+  XCTAssertEqual([gen nextID], 3);
+
+  gen = [[FSTTargetIDGenerator alloc] initWithGeneratorID:1 startingAfterID:4];
+  XCTAssertEqual([gen nextID], 5);
+
+  for (int i = 4; i < 12; ++i) {
+    FSTTargetIDGenerator *gen0 =
+        [[FSTTargetIDGenerator alloc] initWithGeneratorID:0 startingAfterID:i];
+    FSTTargetIDGenerator *gen1 =
+        [[FSTTargetIDGenerator alloc] initWithGeneratorID:1 startingAfterID:i];
+    XCTAssertEqual([gen0 nextID], i + 2 & ~1, @"Skip failed for index %d", i);
+    XCTAssertEqual([gen1 nextID], i + 1 | 1, @"Skip failed for index %d", i);
+  }
+
+  gen = [[FSTTargetIDGenerator alloc] initWithGeneratorID:1 startingAfterID:12];
+  XCTAssertEqual([gen nextID], 13);
+
+  gen = [[FSTTargetIDGenerator alloc] initWithGeneratorID:0 startingAfterID:22];
+  XCTAssertEqual([gen nextID], 24);
+}
+
+- (void)testIncrement {
+  FSTTargetIDGenerator *gen =
+      [[FSTTargetIDGenerator alloc] initWithGeneratorID:0 startingAfterID:0];
+  XCTAssertEqual([gen nextID], 2);
+  XCTAssertEqual([gen nextID], 4);
+  XCTAssertEqual([gen nextID], 6);
+  gen = [[FSTTargetIDGenerator alloc] initWithGeneratorID:0 startingAfterID:46];
+  XCTAssertEqual([gen nextID], 48);
+  XCTAssertEqual([gen nextID], 50);
+  XCTAssertEqual([gen nextID], 52);
+  XCTAssertEqual([gen nextID], 54);
+
+  gen = [[FSTTargetIDGenerator alloc] initWithGeneratorID:1 startingAfterID:0];
+  XCTAssertEqual([gen nextID], 1);
+  XCTAssertEqual([gen nextID], 3);
+  XCTAssertEqual([gen nextID], 5);
+  gen = [[FSTTargetIDGenerator alloc] initWithGeneratorID:1 startingAfterID:46];
+  XCTAssertEqual([gen nextID], 47);
+  XCTAssertEqual([gen nextID], 49);
+  XCTAssertEqual([gen nextID], 51);
+  XCTAssertEqual([gen nextID], 53);
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 88 - 0
Firestore/Example/Tests/Core/FSTTimestampTests.m

@@ -0,0 +1,88 @@
+/*
+ * 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 "Core/FSTTimestamp.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Util/FSTAssert.h"
+
+#import "FSTHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTTimestampTests : XCTestCase
+@end
+
+@implementation FSTTimestampTests
+
+- (void)testFromDate {
+  // Very carefully construct an NSDate that won't lose precision with its milliseconds.
+  NSDate *input = [NSDate dateWithTimeIntervalSinceReferenceDate:1.5];
+
+  FSTTimestamp *actual = [FSTTimestamp timestampWithDate:input];
+  static const int64_t kSecondsFromEpochToReferenceDate = 978307200;
+  XCTAssertEqual(kSecondsFromEpochToReferenceDate + 1, actual.seconds);
+  XCTAssertEqual(500000000, actual.nanos);
+
+  FSTTimestamp *expected =
+      [[FSTTimestamp alloc] initWithSeconds:(kSecondsFromEpochToReferenceDate + 1) nanos:500000000];
+  XCTAssertEqualObjects(expected, actual);
+}
+
+- (void)testSO8601String {
+  NSDate *date = FSTTestDate(1912, 4, 14, 23, 40, 0);
+  FSTTimestamp *timestamp =
+      [[FSTTimestamp alloc] initWithSeconds:(int64_t)date.timeIntervalSince1970 nanos:543000000];
+  XCTAssertEqualObjects(timestamp.ISO8601String, @"1912-04-14T23:40:00.543000000Z");
+}
+
+- (void)testISO8601String_withLowMilliseconds {
+  NSDate *date = FSTTestDate(1912, 4, 14, 23, 40, 0);
+  FSTTimestamp *timestamp =
+      [[FSTTimestamp alloc] initWithSeconds:(int64_t)date.timeIntervalSince1970 nanos:7000000];
+  XCTAssertEqualObjects(timestamp.ISO8601String, @"1912-04-14T23:40:00.007000000Z");
+}
+
+- (void)testISO8601String_withLowNanos {
+  FSTTimestamp *timestamp = [[FSTTimestamp alloc] initWithSeconds:0 nanos:1];
+  XCTAssertEqualObjects(timestamp.ISO8601String, @"1970-01-01T00:00:00.000000001Z");
+}
+
+- (void)testISO8601String_withNegativeSeconds {
+  FSTTimestamp *timestamp = [[FSTTimestamp alloc] initWithSeconds:-1 nanos:999999999];
+  XCTAssertEqualObjects(timestamp.ISO8601String, @"1969-12-31T23:59:59.999999999Z");
+}
+
+- (void)testCompare {
+  NSArray<FSTTimestamp *> *timestamps = @[
+    [[FSTTimestamp alloc] initWithSeconds:12344 nanos:999999999],
+    [[FSTTimestamp alloc] initWithSeconds:12345 nanos:0],
+    [[FSTTimestamp alloc] initWithSeconds:12345 nanos:000000001],
+    [[FSTTimestamp alloc] initWithSeconds:12345 nanos:99999999],
+    [[FSTTimestamp alloc] initWithSeconds:12345 nanos:100000000],
+    [[FSTTimestamp alloc] initWithSeconds:12345 nanos:100000001],
+    [[FSTTimestamp alloc] initWithSeconds:12346 nanos:0],
+  ];
+  for (int i = 0; i < timestamps.count - 1; ++i) {
+    XCTAssertEqual(NSOrderedAscending, [timestamps[i] compare:timestamps[i + 1]]);
+    XCTAssertEqual(NSOrderedDescending, [timestamps[i + 1] compare:timestamps[i]]);
+  }
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 141 - 0
Firestore/Example/Tests/Core/FSTViewSnapshotTest.m

@@ -0,0 +1,141 @@
+/*
+ * 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 "Core/FSTViewSnapshot.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Core/FSTQuery.h"
+#import "Model/FSTDocument.h"
+#import "Model/FSTDocumentSet.h"
+#import "Model/FSTPath.h"
+
+#import "FSTHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTViewSnapshotTests : XCTestCase
+@end
+
+@implementation FSTViewSnapshotTests
+
+- (void)testDocumentChangeConstructor {
+  FSTDocument *doc = FSTTestDoc(@"a/b", 0, @{}, NO);
+  FSTDocumentViewChangeType type = FSTDocumentViewChangeTypeModified;
+  FSTDocumentViewChange *change = [FSTDocumentViewChange changeWithDocument:doc type:type];
+  XCTAssertEqual(change.document, doc);
+  XCTAssertEqual(change.type, type);
+}
+
+- (void)testTrack {
+  FSTDocumentViewChangeSet *set = [FSTDocumentViewChangeSet changeSet];
+
+  FSTDocument *docAdded = FSTTestDoc(@"a/1", 0, @{}, NO);
+  FSTDocument *docRemoved = FSTTestDoc(@"a/2", 0, @{}, NO);
+  FSTDocument *docModified = FSTTestDoc(@"a/3", 0, @{}, NO);
+
+  FSTDocument *docAddedThenModified = FSTTestDoc(@"b/1", 0, @{}, NO);
+  FSTDocument *docAddedThenRemoved = FSTTestDoc(@"b/2", 0, @{}, NO);
+  FSTDocument *docRemovedThenAdded = FSTTestDoc(@"b/3", 0, @{}, NO);
+  FSTDocument *docModifiedThenRemoved = FSTTestDoc(@"b/4", 0, @{}, NO);
+  FSTDocument *docModifiedThenModified = FSTTestDoc(@"b/5", 0, @{}, NO);
+
+  [set addChange:[FSTDocumentViewChange changeWithDocument:docAdded
+                                                      type:FSTDocumentViewChangeTypeAdded]];
+  [set addChange:[FSTDocumentViewChange changeWithDocument:docRemoved
+                                                      type:FSTDocumentViewChangeTypeRemoved]];
+  [set addChange:[FSTDocumentViewChange changeWithDocument:docModified
+                                                      type:FSTDocumentViewChangeTypeModified]];
+
+  [set addChange:[FSTDocumentViewChange changeWithDocument:docAddedThenModified
+                                                      type:FSTDocumentViewChangeTypeAdded]];
+  [set addChange:[FSTDocumentViewChange changeWithDocument:docAddedThenModified
+                                                      type:FSTDocumentViewChangeTypeModified]];
+  [set addChange:[FSTDocumentViewChange changeWithDocument:docAddedThenRemoved
+                                                      type:FSTDocumentViewChangeTypeAdded]];
+  [set addChange:[FSTDocumentViewChange changeWithDocument:docAddedThenRemoved
+                                                      type:FSTDocumentViewChangeTypeRemoved]];
+  [set addChange:[FSTDocumentViewChange changeWithDocument:docRemovedThenAdded
+                                                      type:FSTDocumentViewChangeTypeRemoved]];
+  [set addChange:[FSTDocumentViewChange changeWithDocument:docRemovedThenAdded
+                                                      type:FSTDocumentViewChangeTypeAdded]];
+  [set addChange:[FSTDocumentViewChange changeWithDocument:docModifiedThenRemoved
+                                                      type:FSTDocumentViewChangeTypeModified]];
+  [set addChange:[FSTDocumentViewChange changeWithDocument:docModifiedThenRemoved
+                                                      type:FSTDocumentViewChangeTypeRemoved]];
+  [set addChange:[FSTDocumentViewChange changeWithDocument:docModifiedThenModified
+                                                      type:FSTDocumentViewChangeTypeModified]];
+  [set addChange:[FSTDocumentViewChange changeWithDocument:docModifiedThenModified
+                                                      type:FSTDocumentViewChangeTypeModified]];
+
+  NSArray<FSTDocumentViewChange *> *changes = [set changes];
+  XCTAssertEqual(changes.count, 7);
+
+  XCTAssertEqual(changes[0].document, docAdded);
+  XCTAssertEqual(changes[0].type, FSTDocumentViewChangeTypeAdded);
+
+  XCTAssertEqual(changes[1].document, docRemoved);
+  XCTAssertEqual(changes[1].type, FSTDocumentViewChangeTypeRemoved);
+
+  XCTAssertEqual(changes[2].document, docModified);
+  XCTAssertEqual(changes[2].type, FSTDocumentViewChangeTypeModified);
+
+  XCTAssertEqual(changes[3].document, docAddedThenModified);
+  XCTAssertEqual(changes[3].type, FSTDocumentViewChangeTypeAdded);
+
+  XCTAssertEqual(changes[4].document, docRemovedThenAdded);
+  XCTAssertEqual(changes[4].type, FSTDocumentViewChangeTypeModified);
+
+  XCTAssertEqual(changes[5].document, docModifiedThenRemoved);
+  XCTAssertEqual(changes[5].type, FSTDocumentViewChangeTypeRemoved);
+
+  XCTAssertEqual(changes[6].document, docModifiedThenModified);
+  XCTAssertEqual(changes[6].type, FSTDocumentViewChangeTypeModified);
+}
+
+- (void)testViewSnapshotConstructor {
+  FSTQuery *query = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"a" ]]];
+  FSTDocumentSet *documents = [FSTDocumentSet documentSetWithComparator:FSTDocumentComparatorByKey];
+  FSTDocumentSet *oldDocuments = documents;
+  documents = [documents documentSetByAddingDocument:FSTTestDoc(@"c/a", 1, @{}, NO)];
+  NSArray<FSTDocumentViewChange *> *documentChanges =
+      @[ [FSTDocumentViewChange changeWithDocument:FSTTestDoc(@"c/a", 1, @{}, NO)
+                                              type:FSTDocumentViewChangeTypeAdded] ];
+
+  BOOL fromCache = YES;
+  BOOL hasPendingWrites = NO;
+  BOOL syncStateChanged = YES;
+
+  FSTViewSnapshot *snapshot = [[FSTViewSnapshot alloc] initWithQuery:query
+                                                           documents:documents
+                                                        oldDocuments:oldDocuments
+                                                     documentChanges:documentChanges
+                                                           fromCache:fromCache
+                                                    hasPendingWrites:hasPendingWrites
+                                                    syncStateChanged:syncStateChanged];
+
+  XCTAssertEqual(snapshot.query, query);
+  XCTAssertEqual(snapshot.documents, documents);
+  XCTAssertEqual(snapshot.oldDocuments, oldDocuments);
+  XCTAssertEqual(snapshot.documentChanges, documentChanges);
+  XCTAssertEqual(snapshot.fromCache, fromCache);
+  XCTAssertEqual(snapshot.hasPendingWrites, hasPendingWrites);
+  XCTAssertEqual(snapshot.syncStateChanged, syncStateChanged);
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 618 - 0
Firestore/Example/Tests/Core/FSTViewTests.m

@@ -0,0 +1,618 @@
+/*
+ * 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 "Core/FSTView.h"
+
+#import <XCTest/XCTest.h>
+
+#import "API/FIRFirestore+Internal.h"
+#import "Core/FSTQuery.h"
+#import "Core/FSTViewSnapshot.h"
+#import "Model/FSTDocument.h"
+#import "Model/FSTDocumentKey.h"
+#import "Model/FSTDocumentSet.h"
+#import "Model/FSTFieldValue.h"
+#import "Model/FSTPath.h"
+#import "Remote/FSTRemoteEvent.h"
+
+#import "FSTHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTViewTests : XCTestCase
+@end
+
+@implementation FSTViewTests
+
+/** Returns a new empty query to use for testing. */
+- (FSTQuery *)queryForMessages {
+  return [FSTQuery
+      queryWithPath:[FSTResourcePath pathWithSegments:@[ @"rooms", @"eros", @"messages" ]]];
+}
+
+- (void)testAddsDocumentsBasedOnQuery {
+  FSTQuery *query = [self queryForMessages];
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{@"text" : @"msg1"}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/2", 0, @{@"text" : @"msg2"}, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/other/messages/1", 0, @{@"text" : @"msg3"}, NO);
+
+  FSTViewSnapshot *_Nullable snapshot =
+      FSTTestApplyChanges(view, @[ doc1, doc2, doc3 ],
+                          [FSTTargetChange changeWithDocuments:@[ doc1, doc2, doc3 ]
+                                           currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent]);
+
+  XCTAssertEqual(snapshot.query, query);
+
+  XCTAssertEqualObjects(snapshot.documents.arrayValue, (@[ doc1, doc2 ]));
+
+  XCTAssertEqualObjects(
+      snapshot.documentChanges, (@[
+        [FSTDocumentViewChange changeWithDocument:doc1 type:FSTDocumentViewChangeTypeAdded],
+        [FSTDocumentViewChange changeWithDocument:doc2 type:FSTDocumentViewChangeTypeAdded]
+      ]));
+
+  XCTAssertFalse(snapshot.isFromCache);
+  XCTAssertFalse(snapshot.hasPendingWrites);
+  XCTAssertTrue(snapshot.syncStateChanged);
+}
+
+- (void)testRemovesDocuments {
+  FSTQuery *query = [self queryForMessages];
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{@"text" : @"msg1"}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/2", 0, @{@"text" : @"msg2"}, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/eros/messages/3", 0, @{@"text" : @"msg3"}, NO);
+
+  // initial state
+  FSTTestApplyChanges(view, @[ doc1, doc2 ], nil);
+
+  // delete doc2, add doc3
+  FSTViewSnapshot *snapshot =
+      FSTTestApplyChanges(view, @[ FSTTestDeletedDoc(@"rooms/eros/messages/2", 0), doc3 ],
+                          [FSTTargetChange changeWithDocuments:@[ doc1, doc3 ]
+                                           currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent]);
+
+  XCTAssertEqual(snapshot.query, query);
+
+  XCTAssertEqualObjects(snapshot.documents.arrayValue, (@[ doc1, doc3 ]));
+
+  XCTAssertEqualObjects(
+      snapshot.documentChanges, (@[
+        [FSTDocumentViewChange changeWithDocument:doc2 type:FSTDocumentViewChangeTypeRemoved],
+        [FSTDocumentViewChange changeWithDocument:doc3 type:FSTDocumentViewChangeTypeAdded]
+      ]));
+
+  XCTAssertFalse(snapshot.isFromCache);
+  XCTAssertTrue(snapshot.syncStateChanged);
+}
+
+- (void)testReturnsNilIfThereAreNoChanges {
+  FSTQuery *query = [self queryForMessages];
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{@"text" : @"msg1"}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/2", 0, @{@"text" : @"msg2"}, NO);
+
+  // initial state
+  FSTTestApplyChanges(view, @[ doc1, doc2 ], nil);
+
+  // reapply same docs, no changes
+  FSTViewSnapshot *snapshot = FSTTestApplyChanges(view, @[ doc1, doc2 ], nil);
+  XCTAssertNil(snapshot);
+}
+
+- (void)testDoesNotReturnNilForFirstChanges {
+  FSTQuery *query = [self queryForMessages];
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  FSTViewSnapshot *snapshot = FSTTestApplyChanges(view, @[], nil);
+  XCTAssertNotNil(snapshot);
+}
+
+- (void)testFiltersDocumentsBasedOnQueryWithFilter {
+  FSTQuery *query = [self queryForMessages];
+  FSTRelationFilter *filter =
+      [FSTRelationFilter filterWithField:FSTTestFieldPath(@"sort")
+                          filterOperator:FSTRelationFilterOperatorLessThanOrEqual
+                                   value:[FSTDoubleValue doubleValue:2]];
+  query = [query queryByAddingFilter:filter];
+
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{ @"sort" : @1 }, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/2", 0, @{ @"sort" : @2 }, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/eros/messages/3", 0, @{ @"sort" : @3 }, NO);
+  FSTDocument *doc4 = FSTTestDoc(@"rooms/eros/messages/4", 0, @{}, NO);  // no sort, no match
+  FSTDocument *doc5 = FSTTestDoc(@"rooms/eros/messages/5", 0, @{ @"sort" : @1 }, NO);
+
+  FSTViewSnapshot *snapshot = FSTTestApplyChanges(view, @[ doc1, doc2, doc3, doc4, doc5 ], nil);
+
+  XCTAssertEqual(snapshot.query, query);
+
+  XCTAssertEqualObjects(snapshot.documents.arrayValue, (@[ doc1, doc5, doc2 ]));
+
+  XCTAssertEqualObjects(
+      snapshot.documentChanges, (@[
+        [FSTDocumentViewChange changeWithDocument:doc1 type:FSTDocumentViewChangeTypeAdded],
+        [FSTDocumentViewChange changeWithDocument:doc5 type:FSTDocumentViewChangeTypeAdded],
+        [FSTDocumentViewChange changeWithDocument:doc2 type:FSTDocumentViewChangeTypeAdded]
+      ]));
+
+  XCTAssertTrue(snapshot.isFromCache);
+  XCTAssertTrue(snapshot.syncStateChanged);
+}
+
+- (void)testUpdatesDocumentsBasedOnQueryWithFilter {
+  FSTQuery *query = [self queryForMessages];
+  FSTRelationFilter *filter =
+      [FSTRelationFilter filterWithField:FSTTestFieldPath(@"sort")
+                          filterOperator:FSTRelationFilterOperatorLessThanOrEqual
+                                   value:[FSTDoubleValue doubleValue:2]];
+  query = [query queryByAddingFilter:filter];
+
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{ @"sort" : @1 }, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/2", 0, @{ @"sort" : @3 }, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/eros/messages/3", 0, @{ @"sort" : @2 }, NO);
+  FSTDocument *doc4 = FSTTestDoc(@"rooms/eros/messages/4", 0, @{}, NO);
+
+  FSTViewSnapshot *snapshot = FSTTestApplyChanges(view, @[ doc1, doc2, doc3, doc4 ], nil);
+
+  XCTAssertEqual(snapshot.query, query);
+
+  XCTAssertEqualObjects(snapshot.documents.arrayValue, (@[ doc1, doc3 ]));
+
+  FSTDocument *newDoc2 = FSTTestDoc(@"rooms/eros/messages/2", 1, @{ @"sort" : @2 }, NO);
+  FSTDocument *newDoc3 = FSTTestDoc(@"rooms/eros/messages/3", 1, @{ @"sort" : @3 }, NO);
+  FSTDocument *newDoc4 = FSTTestDoc(@"rooms/eros/messages/4", 1, @{ @"sort" : @0 }, NO);
+
+  snapshot = FSTTestApplyChanges(view, @[ newDoc2, newDoc3, newDoc4 ], nil);
+
+  XCTAssertEqual(snapshot.query, query);
+
+  XCTAssertEqualObjects(snapshot.documents.arrayValue, (@[ newDoc4, doc1, newDoc2 ]));
+
+  XCTAssertEqualObjects(
+      snapshot.documentChanges, (@[
+        [FSTDocumentViewChange changeWithDocument:doc3 type:FSTDocumentViewChangeTypeRemoved],
+        [FSTDocumentViewChange changeWithDocument:newDoc4 type:FSTDocumentViewChangeTypeAdded],
+        [FSTDocumentViewChange changeWithDocument:newDoc2 type:FSTDocumentViewChangeTypeAdded]
+      ]));
+
+  XCTAssertTrue(snapshot.isFromCache);
+  XCTAssertFalse(snapshot.syncStateChanged);
+}
+
+- (void)testRemovesDocumentsForQueryWithLimit {
+  FSTQuery *query = [self queryForMessages];
+  query = [query queryBySettingLimit:2];
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{@"text" : @"msg1"}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/2", 0, @{@"text" : @"msg2"}, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/eros/messages/3", 0, @{@"text" : @"msg3"}, NO);
+
+  // initial state
+  FSTTestApplyChanges(view, @[ doc1, doc3 ], nil);
+
+  // add doc2, which should push out doc3
+  FSTViewSnapshot *snapshot =
+      FSTTestApplyChanges(view, @[ doc2 ],
+                          [FSTTargetChange changeWithDocuments:@[ doc1, doc2, doc3 ]
+                                           currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent]);
+
+  XCTAssertEqual(snapshot.query, query);
+
+  XCTAssertEqualObjects(snapshot.documents.arrayValue, (@[ doc1, doc2 ]));
+
+  XCTAssertEqualObjects(
+      snapshot.documentChanges, (@[
+        [FSTDocumentViewChange changeWithDocument:doc3 type:FSTDocumentViewChangeTypeRemoved],
+        [FSTDocumentViewChange changeWithDocument:doc2 type:FSTDocumentViewChangeTypeAdded]
+      ]));
+
+  XCTAssertFalse(snapshot.isFromCache);
+  XCTAssertTrue(snapshot.syncStateChanged);
+}
+
+- (void)testDoesntReportChangesForDocumentBeyondLimitOfQuery {
+  FSTQuery *query = [self queryForMessages];
+  query =
+      [query queryByAddingSortOrder:[FSTSortOrder sortOrderWithFieldPath:FSTTestFieldPath(@"num")
+                                                               ascending:YES]];
+  query = [query queryBySettingLimit:2];
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{ @"num" : @1 }, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/2", 0, @{ @"num" : @2 }, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/eros/messages/3", 0, @{ @"num" : @3 }, NO);
+  FSTDocument *doc4 = FSTTestDoc(@"rooms/eros/messages/4", 0, @{ @"num" : @4 }, NO);
+
+  // initial state
+  FSTTestApplyChanges(view, @[ doc1, doc2 ], nil);
+
+  // change doc2 to 5, and add doc3 and doc4.
+  // doc2 will be modified + removed = removed
+  // doc3 will be added
+  // doc4 will be added + removed = nothing
+  doc2 = FSTTestDoc(@"rooms/eros/messages/2", 1, @{ @"num" : @5 }, NO);
+  FSTViewDocumentChanges *viewDocChanges =
+      [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc2, doc3, doc4 ])];
+  XCTAssertTrue(viewDocChanges.needsRefill);
+  // Verify that all the docs still match.
+  viewDocChanges = [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc1, doc2, doc3, doc4 ])
+                                     previousChanges:viewDocChanges];
+  FSTViewSnapshot *snapshot =
+      [view applyChangesToDocuments:viewDocChanges
+                       targetChange:[FSTTargetChange
+                                        changeWithDocuments:@[ doc1, doc2, doc3, doc4 ]
+                                        currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent]]
+          .snapshot;
+
+  XCTAssertEqual(snapshot.query, query);
+
+  XCTAssertEqualObjects(snapshot.documents.arrayValue, (@[ doc1, doc3 ]));
+
+  XCTAssertEqualObjects(
+      snapshot.documentChanges, (@[
+        [FSTDocumentViewChange changeWithDocument:doc2 type:FSTDocumentViewChangeTypeRemoved],
+        [FSTDocumentViewChange changeWithDocument:doc3 type:FSTDocumentViewChangeTypeAdded]
+      ]));
+
+  XCTAssertFalse(snapshot.isFromCache);
+  XCTAssertTrue(snapshot.syncStateChanged);
+}
+
+- (void)testKeepsTrackOfLimboDocuments {
+  FSTQuery *query = [self queryForMessages];
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/0", 0, @{}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{}, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/eros/messages/2", 0, @{}, NO);
+
+  FSTViewChange *change = [view
+      applyChangesToDocuments:[view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc1 ])]];
+  XCTAssertEqualObjects(change.limboChanges, @[]);
+
+  change =
+      [view applyChangesToDocuments:[view computeChangesWithDocuments:FSTTestDocUpdates(@[])]
+                       targetChange:[FSTTargetChange
+                                        changeWithDocuments:@[]
+                                        currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent]];
+  XCTAssertEqualObjects(
+      change.limboChanges,
+      @[ [FSTLimboDocumentChange changeWithType:FSTLimboDocumentChangeTypeAdded key:doc1.key] ]);
+
+  change = [view
+      applyChangesToDocuments:[view computeChangesWithDocuments:FSTTestDocUpdates(@[])]
+                 targetChange:[FSTTargetChange changeWithDocuments:@[ doc1 ]
+                                               currentStatusUpdate:FSTCurrentStatusUpdateNone]];
+  XCTAssertEqualObjects(
+      change.limboChanges,
+      @[ [FSTLimboDocumentChange changeWithType:FSTLimboDocumentChangeTypeRemoved key:doc1.key] ]);
+
+  change = [view
+      applyChangesToDocuments:[view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc2 ])]
+                 targetChange:[FSTTargetChange changeWithDocuments:@[ doc2 ]
+                                               currentStatusUpdate:FSTCurrentStatusUpdateNone]];
+  XCTAssertEqualObjects(change.limboChanges, @[]);
+
+  change = [view
+      applyChangesToDocuments:[view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc3 ])]];
+  XCTAssertEqualObjects(
+      change.limboChanges,
+      @[ [FSTLimboDocumentChange changeWithType:FSTLimboDocumentChangeTypeAdded key:doc3.key] ]);
+
+  change = [view applyChangesToDocuments:[view computeChangesWithDocuments:FSTTestDocUpdates(@[
+                                                 FSTTestDeletedDoc(@"rooms/eros/messages/2",
+                                                                   1)
+                                               ])]];  // remove
+  XCTAssertEqualObjects(
+      change.limboChanges,
+      @[ [FSTLimboDocumentChange changeWithType:FSTLimboDocumentChangeTypeRemoved key:doc3.key] ]);
+}
+
+- (void)testResumingQueryCreatesNoLimbos {
+  FSTQuery *query = [self queryForMessages];
+
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/0", 0, @{}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{}, NO);
+
+  // Unlike other cases, here the view is initialized with a set of previously synced documents
+  // which happens when listening to a previously listened-to query.
+  FSTView *view = [[FSTView alloc] initWithQuery:query
+                                 remoteDocuments:FSTTestDocKeySet(@[ doc1.key, doc2.key ])];
+
+  FSTTargetChange *markCurrent =
+      [FSTTargetChange changeWithDocuments:@[]
+                       currentStatusUpdate:FSTCurrentStatusUpdateMarkCurrent];
+  FSTViewDocumentChanges *changes = [view computeChangesWithDocuments:FSTTestDocUpdates(@[])];
+  FSTViewChange *change = [view applyChangesToDocuments:changes targetChange:markCurrent];
+  XCTAssertEqualObjects(change.limboChanges, @[]);
+}
+
+- (void)assertDocSet:(FSTDocumentSet *)docSet containsDocs:(NSArray<FSTDocument *> *)docs {
+  XCTAssertEqual(docs.count, docSet.count);
+  for (FSTDocument *doc in docs) {
+    XCTAssertTrue([docSet containsKey:doc.key]);
+  }
+}
+
+- (void)testReturnsNeedsRefillOnDeleteInLimitQuery {
+  FSTQuery *query = [[self queryForMessages] queryBySettingLimit:2];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/0", 0, @{}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{}, NO);
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  // Start with a full view.
+  FSTViewDocumentChanges *changes =
+      [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc1, doc2 ])];
+  [self assertDocSet:changes.documentSet containsDocs:@[ doc1, doc2 ]];
+  XCTAssertFalse(changes.needsRefill);
+  XCTAssertEqual(2, [changes.changeSet changes].count);
+  [view applyChangesToDocuments:changes];
+
+  // Remove one of the docs.
+  changes = [view computeChangesWithDocuments:FSTTestDocUpdates(@[ FSTTestDeletedDoc(
+                                                  @"rooms/eros/messages/0", 0) ])];
+  [self assertDocSet:changes.documentSet containsDocs:@[ doc2 ]];
+  XCTAssertTrue(changes.needsRefill);
+  XCTAssertEqual(1, [changes.changeSet changes].count);
+  // Refill it with just the one doc remaining.
+  changes = [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc2 ]) previousChanges:changes];
+  [self assertDocSet:changes.documentSet containsDocs:@[ doc2 ]];
+  XCTAssertFalse(changes.needsRefill);
+  XCTAssertEqual(1, [changes.changeSet changes].count);
+  [view applyChangesToDocuments:changes];
+}
+
+- (void)testReturnsNeedsRefillOnReorderInLimitQuery {
+  FSTQuery *query = [self queryForMessages];
+  query =
+      [query queryByAddingSortOrder:[FSTSortOrder sortOrderWithFieldPath:FSTTestFieldPath(@"order")
+                                                               ascending:YES]];
+  query = [query queryBySettingLimit:2];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/0", 0, @{ @"order" : @1 }, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{ @"order" : @2 }, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/eros/messages/2", 0, @{ @"order" : @3 }, NO);
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  // Start with a full view.
+  FSTViewDocumentChanges *changes =
+      [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc1, doc2, doc3 ])];
+  [self assertDocSet:changes.documentSet containsDocs:@[ doc1, doc2 ]];
+  XCTAssertFalse(changes.needsRefill);
+  XCTAssertEqual(2, [changes.changeSet changes].count);
+  [view applyChangesToDocuments:changes];
+
+  // Move one of the docs.
+  doc2 = FSTTestDoc(@"rooms/eros/messages/1", 1, @{ @"order" : @2000 }, NO);
+  changes = [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc2 ])];
+  [self assertDocSet:changes.documentSet containsDocs:@[ doc1, doc2 ]];
+  XCTAssertTrue(changes.needsRefill);
+  XCTAssertEqual(1, [changes.changeSet changes].count);
+  // Refill it with all three current docs.
+  changes = [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc1, doc2, doc3 ])
+                              previousChanges:changes];
+  [self assertDocSet:changes.documentSet containsDocs:@[ doc1, doc3 ]];
+  XCTAssertFalse(changes.needsRefill);
+  XCTAssertEqual(2, [changes.changeSet changes].count);
+  [view applyChangesToDocuments:changes];
+}
+
+- (void)testDoesntNeedRefillOnReorderWithinLimit {
+  FSTQuery *query = [self queryForMessages];
+  query =
+      [query queryByAddingSortOrder:[FSTSortOrder sortOrderWithFieldPath:FSTTestFieldPath(@"order")
+                                                               ascending:YES]];
+  query = [query queryBySettingLimit:3];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/0", 0, @{ @"order" : @1 }, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{ @"order" : @2 }, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/eros/messages/2", 0, @{ @"order" : @3 }, NO);
+  FSTDocument *doc4 = FSTTestDoc(@"rooms/eros/messages/3", 0, @{ @"order" : @4 }, NO);
+  FSTDocument *doc5 = FSTTestDoc(@"rooms/eros/messages/4", 0, @{ @"order" : @5 }, NO);
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  // Start with a full view.
+  FSTViewDocumentChanges *changes =
+      [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc1, doc2, doc3, doc4, doc5 ])];
+  [self assertDocSet:changes.documentSet containsDocs:@[ doc1, doc2, doc3 ]];
+  XCTAssertFalse(changes.needsRefill);
+  XCTAssertEqual(3, [changes.changeSet changes].count);
+  [view applyChangesToDocuments:changes];
+
+  // Move one of the docs.
+  doc1 = FSTTestDoc(@"rooms/eros/messages/0", 1, @{ @"order" : @3 }, NO);
+  changes = [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc1 ])];
+  [self assertDocSet:changes.documentSet containsDocs:@[ doc2, doc3, doc1 ]];
+  XCTAssertFalse(changes.needsRefill);
+  XCTAssertEqual(1, [changes.changeSet changes].count);
+  [view applyChangesToDocuments:changes];
+}
+
+- (void)testDoesntNeedRefillOnReorderAfterLimitQuery {
+  FSTQuery *query = [self queryForMessages];
+  query =
+      [query queryByAddingSortOrder:[FSTSortOrder sortOrderWithFieldPath:FSTTestFieldPath(@"order")
+                                                               ascending:YES]];
+  query = [query queryBySettingLimit:3];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/0", 0, @{ @"order" : @1 }, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{ @"order" : @2 }, NO);
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/eros/messages/2", 0, @{ @"order" : @3 }, NO);
+  FSTDocument *doc4 = FSTTestDoc(@"rooms/eros/messages/3", 0, @{ @"order" : @4 }, NO);
+  FSTDocument *doc5 = FSTTestDoc(@"rooms/eros/messages/4", 0, @{ @"order" : @5 }, NO);
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  // Start with a full view.
+  FSTViewDocumentChanges *changes =
+      [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc1, doc2, doc3, doc4, doc5 ])];
+  [self assertDocSet:changes.documentSet containsDocs:@[ doc1, doc2, doc3 ]];
+  XCTAssertFalse(changes.needsRefill);
+  XCTAssertEqual(3, [changes.changeSet changes].count);
+  [view applyChangesToDocuments:changes];
+
+  // Move one of the docs.
+  doc4 = FSTTestDoc(@"rooms/eros/messages/3", 1, @{ @"order" : @6 }, NO);
+  changes = [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc4 ])];
+  [self assertDocSet:changes.documentSet containsDocs:@[ doc1, doc2, doc3 ]];
+  XCTAssertFalse(changes.needsRefill);
+  XCTAssertEqual(0, [changes.changeSet changes].count);
+  [view applyChangesToDocuments:changes];
+}
+
+- (void)testDoesntNeedRefillForAdditionAfterTheLimit {
+  FSTQuery *query = [[self queryForMessages] queryBySettingLimit:2];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/0", 0, @{}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{}, NO);
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  // Start with a full view.
+  FSTViewDocumentChanges *changes =
+      [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc1, doc2 ])];
+  [self assertDocSet:changes.documentSet containsDocs:@[ doc1, doc2 ]];
+  XCTAssertFalse(changes.needsRefill);
+  XCTAssertEqual(2, [changes.changeSet changes].count);
+  [view applyChangesToDocuments:changes];
+
+  // Add a doc that is past the limit.
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/eros/messages/2", 1, @{}, NO);
+  changes = [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc3 ])];
+  [self assertDocSet:changes.documentSet containsDocs:@[ doc1, doc2 ]];
+  XCTAssertFalse(changes.needsRefill);
+  XCTAssertEqual(0, [changes.changeSet changes].count);
+  [view applyChangesToDocuments:changes];
+}
+
+- (void)testDoesntNeedRefillForDeletionsWhenNotNearTheLimit {
+  FSTQuery *query = [[self queryForMessages] queryBySettingLimit:20];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/0", 0, @{}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{}, NO);
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  FSTViewDocumentChanges *changes =
+      [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc1, doc2 ])];
+  [self assertDocSet:changes.documentSet containsDocs:@[ doc1, doc2 ]];
+  XCTAssertFalse(changes.needsRefill);
+  XCTAssertEqual(2, [changes.changeSet changes].count);
+  [view applyChangesToDocuments:changes];
+
+  // Remove one of the docs.
+  changes = [view computeChangesWithDocuments:FSTTestDocUpdates(@[ FSTTestDeletedDoc(
+                                                  @"rooms/eros/messages/1", 0) ])];
+  [self assertDocSet:changes.documentSet containsDocs:@[ doc1 ]];
+  XCTAssertFalse(changes.needsRefill);
+  XCTAssertEqual(1, [changes.changeSet changes].count);
+  [view applyChangesToDocuments:changes];
+}
+
+- (void)testHandlesApplyingIrrelevantDocs {
+  FSTQuery *query = [[self queryForMessages] queryBySettingLimit:2];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/0", 0, @{}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{}, NO);
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  // Start with a full view.
+  FSTViewDocumentChanges *changes =
+      [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc1, doc2 ])];
+  [self assertDocSet:changes.documentSet containsDocs:@[ doc1, doc2 ]];
+  XCTAssertFalse(changes.needsRefill);
+  XCTAssertEqual(2, [changes.changeSet changes].count);
+  [view applyChangesToDocuments:changes];
+
+  // Remove a doc that isn't even in the results.
+  changes = [view computeChangesWithDocuments:FSTTestDocUpdates(@[ FSTTestDeletedDoc(
+                                                  @"rooms/eros/messages/2", 0) ])];
+  [self assertDocSet:changes.documentSet containsDocs:@[ doc1, doc2 ]];
+  XCTAssertFalse(changes.needsRefill);
+  XCTAssertEqual(0, [changes.changeSet changes].count);
+  [view applyChangesToDocuments:changes];
+}
+
+- (void)testComputesMutatedKeys {
+  FSTQuery *query = [self queryForMessages];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/0", 0, @{}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{}, NO);
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  // Start with a full view.
+  FSTViewDocumentChanges *changes =
+      [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc1, doc2 ])];
+  [view applyChangesToDocuments:changes];
+  XCTAssertEqualObjects(changes.mutatedKeys, FSTTestDocKeySet(@[]));
+
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/eros/messages/2", 0, @{}, YES);
+  changes = [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc3 ])];
+  XCTAssertEqualObjects(changes.mutatedKeys, FSTTestDocKeySet(@[ doc3.key ]));
+}
+
+- (void)testRemovesKeysFromMutatedKeysWhenNewDocHasNoLocalChanges {
+  FSTQuery *query = [self queryForMessages];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/0", 0, @{}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{}, YES);
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  // Start with a full view.
+  FSTViewDocumentChanges *changes =
+      [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc1, doc2 ])];
+  [view applyChangesToDocuments:changes];
+  XCTAssertEqualObjects(changes.mutatedKeys, FSTTestDocKeySet(@[ doc2.key ]));
+
+  FSTDocument *doc2Prime = FSTTestDoc(@"rooms/eros/messages/1", 0, @{}, NO);
+  changes = [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc2Prime ])];
+  [view applyChangesToDocuments:changes];
+  XCTAssertEqualObjects(changes.mutatedKeys, FSTTestDocKeySet(@[]));
+}
+
+- (void)testRemembersLocalMutationsFromPreviousSnapshot {
+  FSTQuery *query = [self queryForMessages];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/0", 0, @{}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{}, YES);
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  // Start with a full view.
+  FSTViewDocumentChanges *changes =
+      [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc1, doc2 ])];
+  [view applyChangesToDocuments:changes];
+  XCTAssertEqualObjects(changes.mutatedKeys, FSTTestDocKeySet(@[ doc2.key ]));
+
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/eros/messages/2", 0, @{}, NO);
+  changes = [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc3 ])];
+  [view applyChangesToDocuments:changes];
+  XCTAssertEqualObjects(changes.mutatedKeys, FSTTestDocKeySet(@[ doc2.key ]));
+}
+
+- (void)testRemembersLocalMutationsFromPreviousCallToComputeChangesWithDocuments {
+  FSTQuery *query = [self queryForMessages];
+  FSTDocument *doc1 = FSTTestDoc(@"rooms/eros/messages/0", 0, @{}, NO);
+  FSTDocument *doc2 = FSTTestDoc(@"rooms/eros/messages/1", 0, @{}, YES);
+  FSTView *view = [[FSTView alloc] initWithQuery:query remoteDocuments:[FSTDocumentKeySet keySet]];
+
+  // Start with a full view.
+  FSTViewDocumentChanges *changes =
+      [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc1, doc2 ])];
+  XCTAssertEqualObjects(changes.mutatedKeys, FSTTestDocKeySet(@[ doc2.key ]));
+
+  FSTDocument *doc3 = FSTTestDoc(@"rooms/eros/messages/2", 0, @{}, NO);
+  changes = [view computeChangesWithDocuments:FSTTestDocUpdates(@[ doc3 ]) previousChanges:changes];
+  XCTAssertEqualObjects(changes.mutatedKeys, FSTTestDocKeySet(@[ doc2.key ]));
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 195 - 0
Firestore/Example/Tests/Integration/API/FIRCursorTests.m

@@ -0,0 +1,195 @@
+/*
+ * 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;
+
+#import <XCTest/XCTest.h>
+
+#import "FSTIntegrationTestCase.h"
+
+@interface FIRCursorTests : FSTIntegrationTestCase
+@end
+
+@implementation FIRCursorTests
+
+- (void)testCanPageThroughItems {
+  FIRCollectionReference *testCollection = [self collectionRefWithDocuments:@{
+    @"a" : @{@"v" : @"a"},
+    @"b" : @{@"v" : @"b"},
+    @"c" : @{@"v" : @"c"},
+    @"d" : @{@"v" : @"d"},
+    @"e" : @{@"v" : @"e"},
+    @"f" : @{@"v" : @"f"}
+  }];
+
+  FIRQuerySnapshot *snapshot = [self readDocumentSetForRef:[testCollection queryLimitedTo:2]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(snapshot), (@[ @{@"v" : @"a"}, @{@"v" : @"b"} ]));
+
+  FIRDocumentSnapshot *lastDoc = snapshot.documents.lastObject;
+  snapshot = [self
+      readDocumentSetForRef:[[testCollection queryLimitedTo:3] queryStartingAfterDocument:lastDoc]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(snapshot),
+                        (@[ @{@"v" : @"c"}, @{@"v" : @"d"}, @{@"v" : @"e"} ]));
+
+  lastDoc = snapshot.documents.lastObject;
+  snapshot = [self
+      readDocumentSetForRef:[[testCollection queryLimitedTo:1] queryStartingAfterDocument:lastDoc]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(snapshot), @[ @{@"v" : @"f"} ]);
+
+  lastDoc = snapshot.documents.lastObject;
+  snapshot = [self
+      readDocumentSetForRef:[[testCollection queryLimitedTo:3] queryStartingAfterDocument:lastDoc]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(snapshot), @[]);
+}
+
+- (void)testCanBeCreatedFromDocuments {
+  FIRCollectionReference *testCollection = [self collectionRefWithDocuments:@{
+    @"a" : @{@"v" : @"a", @"sort" : @1.0},
+    @"b" : @{@"v" : @"b", @"sort" : @2.0},
+    @"c" : @{@"v" : @"c", @"sort" : @2.0},
+    @"d" : @{@"v" : @"d", @"sort" : @2.0},
+    @"e" : @{@"v" : @"e", @"sort" : @0.0},
+    @"f" : @{@"v" : @"f", @"nosort" : @1.0}  // should not show up
+  }];
+
+  FIRQuery *query = [testCollection queryOrderedByField:@"sort"];
+  FIRDocumentSnapshot *snapshot = [self readDocumentForRef:[testCollection documentWithPath:@"c"]];
+
+  XCTAssertTrue(snapshot.exists);
+  FIRQuerySnapshot *querySnapshot =
+      [self readDocumentSetForRef:[query queryStartingAtDocument:snapshot]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(querySnapshot), (@[
+                          @{ @"v" : @"c",
+                             @"sort" : @2.0 },
+                          @{ @"v" : @"d",
+                             @"sort" : @2.0 }
+                        ]));
+
+  querySnapshot = [self readDocumentSetForRef:[query queryEndingBeforeDocument:snapshot]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(querySnapshot), (@[
+                          @{ @"v" : @"e",
+                             @"sort" : @0.0 },
+                          @{ @"v" : @"a",
+                             @"sort" : @1.0 },
+                          @{ @"v" : @"b",
+                             @"sort" : @2.0 }
+                        ]));
+}
+
+- (void)testCanBeCreatedFromValues {
+  FIRCollectionReference *testCollection = [self collectionRefWithDocuments:@{
+    @"a" : @{@"v" : @"a", @"sort" : @1.0},
+    @"b" : @{@"v" : @"b", @"sort" : @2.0},
+    @"c" : @{@"v" : @"c", @"sort" : @2.0},
+    @"d" : @{@"v" : @"d", @"sort" : @2.0},
+    @"e" : @{@"v" : @"e", @"sort" : @0.0},
+    @"f" : @{@"v" : @"f", @"nosort" : @1.0}  // should not show up
+  }];
+
+  FIRQuery *query = [testCollection queryOrderedByField:@"sort"];
+  FIRQuerySnapshot *querySnapshot =
+      [self readDocumentSetForRef:[query queryStartingAtValues:@[ @2.0 ]]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(querySnapshot), (@[
+                          @{ @"v" : @"b",
+                             @"sort" : @2.0 },
+                          @{ @"v" : @"c",
+                             @"sort" : @2.0 },
+                          @{ @"v" : @"d",
+                             @"sort" : @2.0 }
+                        ]));
+
+  querySnapshot = [self readDocumentSetForRef:[query queryEndingBeforeValues:@[ @2.0 ]]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(querySnapshot), (@[
+                          @{ @"v" : @"e",
+                             @"sort" : @0.0 },
+                          @{ @"v" : @"a",
+                             @"sort" : @1.0 }
+                        ]));
+}
+
+- (void)testCanBeCreatedUsingDocumentId {
+  NSDictionary *testDocs = @{
+    @"a" : @{@"k" : @"a"},
+    @"b" : @{@"k" : @"b"},
+    @"c" : @{@"k" : @"c"},
+    @"d" : @{@"k" : @"d"},
+    @"e" : @{@"k" : @"e"}
+  };
+  FIRCollectionReference *writer = [[[[self firestore] collectionWithPath:@"parent-collection"]
+      documentWithAutoID] collectionWithPath:@"sub-collection"];
+  [self writeAllDocuments:testDocs toCollection:writer];
+
+  FIRCollectionReference *reader = [[self firestore] collectionWithPath:writer.path];
+  FIRQuerySnapshot *querySnapshot =
+      [self readDocumentSetForRef:[[[reader queryOrderedByFieldPath:[FIRFieldPath documentID]]
+                                      queryStartingAtValues:@[ @"b" ]]
+                                      queryEndingBeforeValues:@[ @"d" ]]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(querySnapshot),
+                        (@[ @{@"k" : @"b"}, @{@"k" : @"c"} ]));
+}
+
+- (void)testCanBeUsedWithReferenceValues {
+  FIRFirestore *db = [self firestore];
+
+  FIRCollectionReference *testCollection = [self collectionRefWithDocuments:@{
+    @"a" : @{@"k" : @"1a", @"ref" : [db documentWithPath:@"1/a"]},
+    @"b" : @{@"k" : @"1b", @"ref" : [db documentWithPath:@"1/b"]},
+    @"c" : @{@"k" : @"2a", @"ref" : [db documentWithPath:@"2/a"]},
+    @"d" : @{@"k" : @"2b", @"ref" : [db documentWithPath:@"2/b"]},
+    @"e" : @{@"k" : @"3a", @"ref" : [db documentWithPath:@"3/a"]},
+  }];
+  FIRQuery *query = [testCollection queryOrderedByField:@"ref"];
+  FIRQuerySnapshot *querySnapshot = [self
+      readDocumentSetForRef:[[query queryStartingAfterValues:@[ [db documentWithPath:@"1/a"] ]]
+                                queryEndingAtValues:@[ [db documentWithPath:@"2/b"] ]]];
+  NSMutableArray<NSString *> *actual = [NSMutableArray array];
+  [querySnapshot.documents enumerateObjectsUsingBlock:^(FIRDocumentSnapshot *_Nonnull doc,
+                                                        NSUInteger idx, BOOL *_Nonnull stop) {
+    [actual addObject:doc.data[@"k"]];
+  }];
+  XCTAssertEqualObjects(actual, (@[ @"1b", @"2a", @"2b" ]));
+}
+
+- (void)testCanBeUsedInDescendingQueries {
+  FIRCollectionReference *testCollection = [self collectionRefWithDocuments:@{
+    @"a" : @{@"v" : @"a", @"sort" : @1.0},
+    @"b" : @{@"v" : @"b", @"sort" : @2.0},
+    @"c" : @{@"v" : @"c", @"sort" : @2.0},
+    @"d" : @{@"v" : @"d", @"sort" : @3.0},
+    @"e" : @{@"v" : @"e", @"sort" : @0.0},
+    @"f" : @{@"v" : @"f", @"nosort" : @1.0}  // should not show up
+  }];
+  FIRQuery *query = [[testCollection queryOrderedByField:@"sort" descending:YES]
+      queryOrderedByFieldPath:[FIRFieldPath documentID]
+                   descending:YES];
+
+  FIRQuerySnapshot *snapshot = [self readDocumentSetForRef:[query queryStartingAtValues:@[ @2.0 ]]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(snapshot), (@[
+                          @{ @"v" : @"c",
+                             @"sort" : @2.0 },
+                          @{ @"v" : @"b",
+                             @"sort" : @2.0 },
+                          @{ @"v" : @"a",
+                             @"sort" : @1.0 },
+                          @{ @"v" : @"e",
+                             @"sort" : @0.0 }
+                        ]));
+
+  snapshot = [self readDocumentSetForRef:[query queryEndingBeforeValues:@[ @2.0 ]]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(snapshot), (@[ @{ @"v" : @"d", @"sort" : @3.0 } ]));
+}
+
+@end

+ 741 - 0
Firestore/Example/Tests/Integration/API/FIRDatabaseTests.m

@@ -0,0 +1,741 @@
+/*
+ * 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;
+
+#import <XCTest/XCTest.h>
+
+#import "FSTIntegrationTestCase.h"
+
+@interface FIRDatabaseTests : FSTIntegrationTestCase
+@end
+
+@implementation FIRDatabaseTests
+
+- (void)testCanUpdateAnExistingDocument {
+  FIRDocumentReference *doc = [self.db documentWithPath:@"rooms/eros"];
+  NSDictionary<NSString *, id> *initialData =
+      @{ @"desc" : @"Description",
+         @"owner" : @{@"name" : @"Jonny", @"email" : @"abc@xyz.com"} };
+  NSDictionary<NSString *, id> *updateData =
+      @{@"desc" : @"NewDescription", @"owner.email" : @"new@xyz.com"};
+  NSDictionary<NSString *, id> *finalData =
+      @{ @"desc" : @"NewDescription",
+         @"owner" : @{@"name" : @"Jonny", @"email" : @"new@xyz.com"} };
+
+  [self writeDocumentRef:doc data:initialData];
+
+  XCTestExpectation *updateCompletion = [self expectationWithDescription:@"updateData"];
+  [doc updateData:updateData
+       completion:^(NSError *_Nullable error) {
+         XCTAssertNil(error);
+         [updateCompletion fulfill];
+       }];
+  [self awaitExpectations];
+
+  FIRDocumentSnapshot *result = [self readDocumentForRef:doc];
+  XCTAssertTrue(result.exists);
+  XCTAssertEqualObjects(result.data, finalData);
+}
+
+- (void)testCanDeleteAFieldWithAnUpdate {
+  FIRDocumentReference *doc = [self.db documentWithPath:@"rooms/eros"];
+  NSDictionary<NSString *, id> *initialData =
+      @{ @"desc" : @"Description",
+         @"owner" : @{@"name" : @"Jonny", @"email" : @"abc@xyz.com"} };
+  NSDictionary<NSString *, id> *updateData =
+      @{@"owner.email" : [FIRFieldValue fieldValueForDelete]};
+  NSDictionary<NSString *, id> *finalData =
+      @{ @"desc" : @"Description",
+         @"owner" : @{@"name" : @"Jonny"} };
+
+  [self writeDocumentRef:doc data:initialData];
+  [self updateDocumentRef:doc data:updateData];
+
+  FIRDocumentSnapshot *result = [self readDocumentForRef:doc];
+  XCTAssertTrue(result.exists);
+  XCTAssertEqualObjects(result.data, finalData);
+}
+
+- (void)testDeleteDocument {
+  FIRDocumentReference *doc = [self.db documentWithPath:@"rooms/eros"];
+  NSDictionary<NSString *, id> *data = @{@"value" : @"foo"};
+  [self writeDocumentRef:doc data:data];
+  FIRDocumentSnapshot *result = [self readDocumentForRef:doc];
+  XCTAssertEqualObjects(result.data, data);
+  [self deleteDocumentRef:doc];
+  result = [self readDocumentForRef:doc];
+  XCTAssertFalse(result.exists);
+}
+
+- (void)testCannotUpdateNonexistentDocument {
+  FIRDocumentReference *doc = [[self.db collectionWithPath:@"rooms"] documentWithAutoID];
+
+  XCTestExpectation *setCompletion = [self expectationWithDescription:@"setData"];
+  [doc updateData:@{@"owner" : @"abc"}
+       completion:^(NSError *_Nullable error) {
+         XCTAssertNotNil(error);
+         XCTAssertEqualObjects(error.domain, FIRFirestoreErrorDomain);
+         XCTAssertEqual(error.code, FIRFirestoreErrorCodeNotFound);
+         [setCompletion fulfill];
+       }];
+  [self awaitExpectations];
+  FIRDocumentSnapshot *result = [self readDocumentForRef:doc];
+  XCTAssertFalse(result.exists);
+}
+
+- (void)testCanOverwriteDataAnExistingDocumentUsingSet {
+  FIRDocumentReference *doc = [[self.db collectionWithPath:@"rooms"] documentWithAutoID];
+
+  NSDictionary<NSString *, id> *initialData =
+      @{ @"desc" : @"Description",
+         @"owner" : @{@"name" : @"Jonny", @"email" : @"abc@xyz.com"} };
+  NSDictionary<NSString *, id> *udpateData = @{@"desc" : @"NewDescription"};
+
+  [self writeDocumentRef:doc data:initialData];
+  [self writeDocumentRef:doc data:udpateData];
+
+  FIRDocumentSnapshot *document = [self readDocumentForRef:doc];
+  XCTAssertEqualObjects(document.data, udpateData);
+}
+
+- (void)testCanMergeDataWithAnExistingDocumentUsingSet {
+  FIRDocumentReference *doc = [[self.db collectionWithPath:@"rooms"] documentWithAutoID];
+
+  NSDictionary<NSString *, id> *initialData = @{
+    @"desc" : @"Description",
+    @"owner.data" : @{@"name" : @"Jonny", @"email" : @"abc@xyz.com"}
+  };
+  NSDictionary<NSString *, id> *updateData =
+      @{ @"updated" : @YES,
+         @"owner.data" : @{@"name" : @"Sebastian"} };
+  NSDictionary<NSString *, id> *finalData = @{
+    @"desc" : @"Description",
+    @"updated" : @YES,
+    @"owner.data" : @{@"name" : @"Sebastian", @"email" : @"abc@xyz.com"}
+  };
+
+  [self writeDocumentRef:doc data:initialData];
+
+  XCTestExpectation *completed =
+      [self expectationWithDescription:@"testCanMergeDataWithAnExistingDocumentUsingSet"];
+
+  [doc setData:updateData
+         options:[FIRSetOptions merge]
+      completion:^(NSError *error) {
+        XCTAssertNil(error);
+        [completed fulfill];
+      }];
+
+  [self awaitExpectations];
+
+  FIRDocumentSnapshot *document = [self readDocumentForRef:doc];
+  XCTAssertEqualObjects(document.data, finalData);
+}
+
+- (void)testMergeReplacesArrays {
+  FIRDocumentReference *doc = [[self.db collectionWithPath:@"rooms"] documentWithAutoID];
+
+  NSDictionary<NSString *, id> *initialData = @{
+    @"untouched" : @YES,
+    @"data" : @"old",
+    @"topLevel" : @[ @"old", @"old" ],
+    @"mapInArray" : @[ @{@"data" : @"old"} ]
+  };
+  NSDictionary<NSString *, id> *updateData =
+      @{ @"data" : @"new",
+         @"topLevel" : @[ @"new" ],
+         @"mapInArray" : @[ @{@"data" : @"new"} ] };
+  NSDictionary<NSString *, id> *finalData = @{
+    @"untouched" : @YES,
+    @"data" : @"new",
+    @"topLevel" : @[ @"new" ],
+    @"mapInArray" : @[ @{@"data" : @"new"} ]
+  };
+
+  [self writeDocumentRef:doc data:initialData];
+
+  XCTestExpectation *completed =
+      [self expectationWithDescription:@"testCanMergeDataWithAnExistingDocumentUsingSet"];
+
+  [doc setData:updateData
+         options:[FIRSetOptions merge]
+      completion:^(NSError *error) {
+        XCTAssertNil(error);
+        [completed fulfill];
+      }];
+
+  [self awaitExpectations];
+
+  FIRDocumentSnapshot *document = [self readDocumentForRef:doc];
+  XCTAssertEqualObjects(document.data, finalData);
+}
+
+- (void)testAddingToACollectionYieldsTheCorrectDocumentReference {
+  FIRCollectionReference *coll = [self.db collectionWithPath:@"collection"];
+  FIRDocumentReference *ref = [coll addDocumentWithData:@{ @"foo" : @1 }];
+
+  XCTestExpectation *getCompletion = [self expectationWithDescription:@"getData"];
+  [ref getDocumentWithCompletion:^(FIRDocumentSnapshot *_Nullable document,
+                                   NSError *_Nullable error) {
+    XCTAssertNil(error);
+    XCTAssertEqualObjects(document.data, (@{ @"foo" : @1 }));
+
+    [getCompletion fulfill];
+  }];
+  [self awaitExpectations];
+}
+
+- (void)testListenCanBeCalledMultipleTimes {
+  FIRCollectionReference *coll = [self.db collectionWithPath:@"collection"];
+  FIRDocumentReference *doc = [coll documentWithAutoID];
+
+  XCTestExpectation *completed = [self expectationWithDescription:@"multiple addSnapshotListeners"];
+
+  __block NSDictionary<NSString *, id> *resultingData;
+
+  // Shut the compiler up about strong references to doc.
+  FIRDocumentReference *__weak weakDoc = doc;
+
+  [doc setData:@{@"foo" : @"bar"}
+      completion:^(NSError *error1) {
+        XCTAssertNil(error1);
+        FIRDocumentReference *strongDoc = weakDoc;
+
+        [strongDoc addSnapshotListener:^(FIRDocumentSnapshot *snapshot2, NSError *error2) {
+          XCTAssertNil(error2);
+
+          FIRDocumentReference *strongDoc2 = weakDoc;
+          [strongDoc2 addSnapshotListener:^(FIRDocumentSnapshot *snapshot3, NSError *error3) {
+            XCTAssertNil(error3);
+            resultingData = snapshot3.data;
+            [completed fulfill];
+          }];
+        }];
+      }];
+
+  [self awaitExpectations];
+  XCTAssertEqualObjects(resultingData, @{@"foo" : @"bar"});
+}
+
+- (void)testDocumentSnapshotEvents_nonExistent {
+  FIRDocumentReference *docRef = [[self.db collectionWithPath:@"rooms"] documentWithAutoID];
+
+  XCTestExpectation *snapshotCompletion = [self expectationWithDescription:@"snapshot"];
+  __block int callbacks = 0;
+
+  id<FIRListenerRegistration> listenerRegistration =
+      [docRef addSnapshotListener:^(FIRDocumentSnapshot *_Nullable doc, NSError *error) {
+        callbacks++;
+
+        if (callbacks == 1) {
+          XCTAssertNotNil(doc);
+          XCTAssertFalse(doc.exists);
+          [snapshotCompletion fulfill];
+
+        } else if (callbacks == 2) {
+          XCTFail("Should not have received this callback");
+        }
+      }];
+
+  [self awaitExpectations];
+
+  [listenerRegistration remove];
+}
+
+- (void)testDocumentSnapshotEvents_forAdd {
+  FIRDocumentReference *docRef = [[self.db collectionWithPath:@"rooms"] documentWithAutoID];
+
+  XCTestExpectation *emptyCompletion = [self expectationWithDescription:@"empty snapshot"];
+  __block XCTestExpectation *dataCompletion;
+  __block int callbacks = 0;
+
+  id<FIRListenerRegistration> listenerRegistration =
+      [docRef addSnapshotListener:^(FIRDocumentSnapshot *_Nullable doc, NSError *error) {
+        callbacks++;
+
+        if (callbacks == 1) {
+          XCTAssertNotNil(doc);
+          XCTAssertFalse(doc.exists);
+          [emptyCompletion fulfill];
+
+        } else if (callbacks == 2) {
+          XCTAssertEqualObjects(doc.data, (@{ @"a" : @1 }));
+          XCTAssertEqual(doc.metadata.hasPendingWrites, YES);
+          [dataCompletion fulfill];
+
+        } else if (callbacks == 3) {
+          XCTFail("Should not have received this callback");
+        }
+      }];
+
+  [self awaitExpectations];
+  dataCompletion = [self expectationWithDescription:@"data snapshot"];
+
+  [docRef setData:@{ @"a" : @1 }];
+  [self awaitExpectations];
+
+  [listenerRegistration remove];
+}
+
+- (void)testDocumentSnapshotEvents_forAddIncludingMetadata {
+  FIRDocumentReference *docRef = [[self.db collectionWithPath:@"rooms"] documentWithAutoID];
+
+  XCTestExpectation *emptyCompletion = [self expectationWithDescription:@"empty snapshot"];
+  __block XCTestExpectation *dataCompletion;
+  __block int callbacks = 0;
+
+  FIRDocumentListenOptions *options =
+      [[FIRDocumentListenOptions options] includeMetadataChanges:YES];
+
+  id<FIRListenerRegistration> listenerRegistration =
+      [docRef addSnapshotListenerWithOptions:options
+                                    listener:^(FIRDocumentSnapshot *_Nullable doc, NSError *error) {
+                                      callbacks++;
+
+                                      if (callbacks == 1) {
+                                        XCTAssertNotNil(doc);
+                                        XCTAssertFalse(doc.exists);
+                                        [emptyCompletion fulfill];
+
+                                      } else if (callbacks == 2) {
+                                        XCTAssertEqualObjects(doc.data, (@{ @"a" : @1 }));
+                                        XCTAssertEqual(doc.metadata.hasPendingWrites, YES);
+
+                                      } else if (callbacks == 3) {
+                                        XCTAssertEqualObjects(doc.data, (@{ @"a" : @1 }));
+                                        XCTAssertEqual(doc.metadata.hasPendingWrites, NO);
+                                        [dataCompletion fulfill];
+
+                                      } else if (callbacks == 4) {
+                                        XCTFail("Should not have received this callback");
+                                      }
+                                    }];
+
+  [self awaitExpectations];
+  dataCompletion = [self expectationWithDescription:@"data snapshot"];
+
+  [docRef setData:@{ @"a" : @1 }];
+  [self awaitExpectations];
+
+  [listenerRegistration remove];
+}
+
+- (void)testDocumentSnapshotEvents_forChange {
+  FIRDocumentReference *docRef = [[self.db collectionWithPath:@"rooms"] documentWithAutoID];
+
+  NSDictionary<NSString *, id> *initialData = @{ @"a" : @1 };
+  NSDictionary<NSString *, id> *changedData = @{ @"b" : @2 };
+
+  [self writeDocumentRef:docRef data:initialData];
+
+  XCTestExpectation *initialCompletion = [self expectationWithDescription:@"initial data"];
+  __block XCTestExpectation *changeCompletion;
+  __block int callbacks = 0;
+
+  id<FIRListenerRegistration> listenerRegistration =
+      [docRef addSnapshotListener:^(FIRDocumentSnapshot *_Nullable doc, NSError *error) {
+        callbacks++;
+
+        if (callbacks == 1) {
+          XCTAssertEqualObjects(doc.data, initialData);
+          XCTAssertEqual(doc.metadata.hasPendingWrites, NO);
+          [initialCompletion fulfill];
+
+        } else if (callbacks == 2) {
+          XCTAssertEqualObjects(doc.data, changedData);
+          XCTAssertEqual(doc.metadata.hasPendingWrites, YES);
+          [changeCompletion fulfill];
+
+        } else if (callbacks == 3) {
+          XCTFail("Should not have received this callback");
+        }
+      }];
+
+  [self awaitExpectations];
+  changeCompletion = [self expectationWithDescription:@"listen for changed data"];
+
+  [docRef setData:changedData];
+  [self awaitExpectations];
+
+  [listenerRegistration remove];
+}
+
+- (void)testDocumentSnapshotEvents_forChangeIncludingMetadata {
+  FIRDocumentReference *docRef = [[self.db collectionWithPath:@"rooms"] documentWithAutoID];
+
+  NSDictionary<NSString *, id> *initialData = @{ @"a" : @1 };
+  NSDictionary<NSString *, id> *changedData = @{ @"b" : @2 };
+
+  [self writeDocumentRef:docRef data:initialData];
+
+  XCTestExpectation *initialCompletion = [self expectationWithDescription:@"initial data"];
+  __block XCTestExpectation *changeCompletion;
+  __block int callbacks = 0;
+
+  FIRDocumentListenOptions *options =
+      [[FIRDocumentListenOptions options] includeMetadataChanges:YES];
+
+  id<FIRListenerRegistration> listenerRegistration =
+      [docRef addSnapshotListenerWithOptions:options
+                                    listener:^(FIRDocumentSnapshot *_Nullable doc, NSError *error) {
+                                      callbacks++;
+
+                                      if (callbacks == 1) {
+                                        XCTAssertEqualObjects(doc.data, initialData);
+                                        XCTAssertEqual(doc.metadata.hasPendingWrites, NO);
+                                        XCTAssertEqual(doc.metadata.isFromCache, YES);
+
+                                      } else if (callbacks == 2) {
+                                        XCTAssertEqualObjects(doc.data, initialData);
+                                        XCTAssertEqual(doc.metadata.hasPendingWrites, NO);
+                                        XCTAssertEqual(doc.metadata.isFromCache, NO);
+                                        [initialCompletion fulfill];
+
+                                      } else if (callbacks == 3) {
+                                        XCTAssertEqualObjects(doc.data, changedData);
+                                        XCTAssertEqual(doc.metadata.hasPendingWrites, YES);
+                                        XCTAssertEqual(doc.metadata.isFromCache, NO);
+
+                                      } else if (callbacks == 4) {
+                                        XCTAssertEqualObjects(doc.data, changedData);
+                                        XCTAssertEqual(doc.metadata.hasPendingWrites, NO);
+                                        XCTAssertEqual(doc.metadata.isFromCache, NO);
+                                        [changeCompletion fulfill];
+
+                                      } else if (callbacks == 5) {
+                                        XCTFail("Should not have received this callback");
+                                      }
+                                    }];
+
+  [self awaitExpectations];
+  changeCompletion = [self expectationWithDescription:@"listen for changed data"];
+
+  [docRef setData:changedData];
+  [self awaitExpectations];
+
+  [listenerRegistration remove];
+}
+
+- (void)testDocumentSnapshotEvents_forDelete {
+  FIRDocumentReference *docRef = [[self.db collectionWithPath:@"rooms"] documentWithAutoID];
+
+  NSDictionary<NSString *, id> *initialData = @{ @"a" : @1 };
+
+  [self writeDocumentRef:docRef data:initialData];
+
+  XCTestExpectation *initialCompletion = [self expectationWithDescription:@"initial data"];
+  __block XCTestExpectation *changeCompletion;
+  __block int callbacks = 0;
+
+  id<FIRListenerRegistration> listenerRegistration =
+      [docRef addSnapshotListener:^(FIRDocumentSnapshot *_Nullable doc, NSError *error) {
+        callbacks++;
+
+        if (callbacks == 1) {
+          XCTAssertEqualObjects(doc.data, initialData);
+          XCTAssertEqual(doc.metadata.hasPendingWrites, NO);
+          XCTAssertEqual(doc.metadata.isFromCache, YES);
+          [initialCompletion fulfill];
+
+        } else if (callbacks == 2) {
+          XCTAssertFalse(doc.exists);
+          [changeCompletion fulfill];
+
+        } else if (callbacks == 3) {
+          XCTFail("Should not have received this callback");
+        }
+      }];
+
+  [self awaitExpectations];
+  changeCompletion = [self expectationWithDescription:@"listen for changed data"];
+
+  [docRef deleteDocument];
+  [self awaitExpectations];
+
+  [listenerRegistration remove];
+}
+
+- (void)testDocumentSnapshotEvents_forDeleteIncludingMetadata {
+  FIRDocumentReference *docRef = [[self.db collectionWithPath:@"rooms"] documentWithAutoID];
+
+  NSDictionary<NSString *, id> *initialData = @{ @"a" : @1 };
+
+  [self writeDocumentRef:docRef data:initialData];
+
+  FIRDocumentListenOptions *options =
+      [[FIRDocumentListenOptions options] includeMetadataChanges:YES];
+
+  XCTestExpectation *initialCompletion = [self expectationWithDescription:@"initial data"];
+  __block XCTestExpectation *changeCompletion;
+  __block int callbacks = 0;
+
+  id<FIRListenerRegistration> listenerRegistration =
+      [docRef addSnapshotListenerWithOptions:options
+                                    listener:^(FIRDocumentSnapshot *_Nullable doc, NSError *error) {
+                                      callbacks++;
+
+                                      if (callbacks == 1) {
+                                        XCTAssertEqualObjects(doc.data, initialData);
+                                        XCTAssertEqual(doc.metadata.hasPendingWrites, NO);
+                                        XCTAssertEqual(doc.metadata.isFromCache, YES);
+
+                                      } else if (callbacks == 2) {
+                                        XCTAssertEqualObjects(doc.data, initialData);
+                                        XCTAssertEqual(doc.metadata.hasPendingWrites, NO);
+                                        XCTAssertEqual(doc.metadata.isFromCache, NO);
+                                        [initialCompletion fulfill];
+
+                                      } else if (callbacks == 3) {
+                                        XCTAssertFalse(doc.exists);
+                                        XCTAssertEqual(doc.metadata.hasPendingWrites, NO);
+                                        XCTAssertEqual(doc.metadata.isFromCache, NO);
+                                        [changeCompletion fulfill];
+
+                                      } else if (callbacks == 4) {
+                                        XCTFail("Should not have received this callback");
+                                      }
+                                    }];
+
+  [self awaitExpectations];
+  changeCompletion = [self expectationWithDescription:@"listen for changed data"];
+
+  [docRef deleteDocument];
+  [self awaitExpectations];
+
+  [listenerRegistration remove];
+}
+
+- (void)testQuerySnapshotEvents_forAdd {
+  FIRCollectionReference *roomsRef = [self collectionRef];
+  FIRDocumentReference *docRef = [roomsRef documentWithAutoID];
+
+  NSDictionary<NSString *, id> *newData = @{ @"a" : @1 };
+
+  XCTestExpectation *emptyCompletion = [self expectationWithDescription:@"empty snapshot"];
+  __block XCTestExpectation *changeCompletion;
+  __block int callbacks = 0;
+
+  id<FIRListenerRegistration> listenerRegistration =
+      [roomsRef addSnapshotListener:^(FIRQuerySnapshot *_Nullable docSet, NSError *error) {
+        callbacks++;
+
+        if (callbacks == 1) {
+          XCTAssertEqual(docSet.count, 0);
+          [emptyCompletion fulfill];
+
+        } else if (callbacks == 2) {
+          XCTAssertEqual(docSet.count, 1);
+          XCTAssertEqualObjects(docSet.documents[0].data, newData);
+          XCTAssertEqual(docSet.documents[0].metadata.hasPendingWrites, YES);
+          [changeCompletion fulfill];
+
+        } else if (callbacks == 3) {
+          XCTFail("Should not have received a third callback");
+        }
+      }];
+
+  [self awaitExpectations];
+  changeCompletion = [self expectationWithDescription:@"changed snapshot"];
+
+  [docRef setData:newData];
+  [self awaitExpectations];
+
+  [listenerRegistration remove];
+}
+
+- (void)testQuerySnapshotEvents_forChange {
+  FIRCollectionReference *roomsRef = [self collectionRef];
+  FIRDocumentReference *docRef = [roomsRef documentWithAutoID];
+
+  NSDictionary<NSString *, id> *initialData = @{ @"a" : @1 };
+  NSDictionary<NSString *, id> *changedData = @{ @"b" : @2 };
+
+  [self writeDocumentRef:docRef data:initialData];
+
+  XCTestExpectation *initialCompletion = [self expectationWithDescription:@"initial data"];
+  __block XCTestExpectation *changeCompletion;
+  __block int callbacks = 0;
+
+  id<FIRListenerRegistration> listenerRegistration =
+      [roomsRef addSnapshotListener:^(FIRQuerySnapshot *_Nullable docSet, NSError *error) {
+        callbacks++;
+
+        if (callbacks == 1) {
+          XCTAssertEqual(docSet.count, 1);
+          XCTAssertEqualObjects(docSet.documents[0].data, initialData);
+          XCTAssertEqual(docSet.documents[0].metadata.hasPendingWrites, NO);
+          [initialCompletion fulfill];
+
+        } else if (callbacks == 2) {
+          XCTAssertEqual(docSet.count, 1);
+          XCTAssertEqualObjects(docSet.documents[0].data, changedData);
+          XCTAssertEqual(docSet.documents[0].metadata.hasPendingWrites, YES);
+          [changeCompletion fulfill];
+
+        } else if (callbacks == 3) {
+          XCTFail("Should not have received a third callback");
+        }
+      }];
+
+  [self awaitExpectations];
+  changeCompletion = [self expectationWithDescription:@"listen for changed data"];
+
+  [docRef setData:changedData];
+  [self awaitExpectations];
+
+  [listenerRegistration remove];
+}
+
+- (void)testQuerySnapshotEvents_forDelete {
+  FIRCollectionReference *roomsRef = [self collectionRef];
+  FIRDocumentReference *docRef = [roomsRef documentWithAutoID];
+
+  NSDictionary<NSString *, id> *initialData = @{ @"a" : @1 };
+
+  [self writeDocumentRef:docRef data:initialData];
+
+  XCTestExpectation *initialCompletion = [self expectationWithDescription:@"initial data"];
+  __block XCTestExpectation *changeCompletion;
+  __block int callbacks = 0;
+
+  id<FIRListenerRegistration> listenerRegistration =
+      [roomsRef addSnapshotListener:^(FIRQuerySnapshot *_Nullable docSet, NSError *error) {
+        callbacks++;
+
+        if (callbacks == 1) {
+          XCTAssertEqual(docSet.count, 1);
+          XCTAssertEqualObjects(docSet.documents[0].data, initialData);
+          XCTAssertEqual(docSet.documents[0].metadata.hasPendingWrites, NO);
+          [initialCompletion fulfill];
+
+        } else if (callbacks == 2) {
+          XCTAssertEqual(docSet.count, 0);
+          [changeCompletion fulfill];
+
+        } else if (callbacks == 4) {
+          XCTFail("Should not have received a third callback");
+        }
+      }];
+
+  [self awaitExpectations];
+  changeCompletion = [self expectationWithDescription:@"listen for changed data"];
+
+  [docRef deleteDocument];
+  [self awaitExpectations];
+
+  [listenerRegistration remove];
+}
+
+- (void)testExposesFirestoreOnDocumentReferences {
+  FIRDocumentReference *doc = [self.db documentWithPath:@"foo/bar"];
+  XCTAssertEqual(doc.firestore, self.db);
+}
+
+- (void)testExposesFirestoreOnQueries {
+  FIRQuery *q = [[self.db collectionWithPath:@"foo"] queryLimitedTo:5];
+  XCTAssertEqual(q.firestore, self.db);
+}
+
+- (void)testCanTraverseCollectionsAndDocuments {
+  NSString *expected = @"a/b/c/d";
+  // doc path from root Firestore.
+  XCTAssertEqualObjects([self.db documentWithPath:@"a/b/c/d"].path, expected);
+  // collection path from root Firestore.
+  XCTAssertEqualObjects([[self.db collectionWithPath:@"a/b/c"] documentWithPath:@"d"].path,
+                        expected);
+  // doc path from CollectionReference.
+  XCTAssertEqualObjects([[self.db collectionWithPath:@"a"] documentWithPath:@"b/c/d"].path,
+                        expected);
+  // collection path from DocumentReference.
+  XCTAssertEqualObjects([[self.db documentWithPath:@"a/b"] collectionWithPath:@"c/d/e"].path,
+                        @"a/b/c/d/e");
+}
+
+- (void)testCanTraverseCollectionAndDocumentParents {
+  FIRCollectionReference *collection = [self.db collectionWithPath:@"a/b/c"];
+  XCTAssertEqualObjects(collection.path, @"a/b/c");
+
+  FIRDocumentReference *doc = collection.parent;
+  XCTAssertEqualObjects(doc.path, @"a/b");
+
+  collection = doc.parent;
+  XCTAssertEqualObjects(collection.path, @"a");
+
+  FIRDocumentReference *nilDoc = collection.parent;
+  XCTAssertNil(nilDoc);
+}
+
+- (void)testUpdateFieldsWithDots {
+  FIRDocumentReference *doc = [self documentRef];
+
+  [self writeDocumentRef:doc data:@{@"a.b" : @"old", @"c.d" : @"old"}];
+
+  [self updateDocumentRef:doc data:@{ [[FIRFieldPath alloc] initWithFields:@[ @"a.b" ]] : @"new" }];
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"testUpdateFieldsWithDots"];
+
+  [doc getDocumentWithCompletion:^(FIRDocumentSnapshot *snapshot, NSError *error) {
+    XCTAssertNil(error);
+    XCTAssertEqualObjects(snapshot.data, (@{@"a.b" : @"new", @"c.d" : @"old"}));
+    [expectation fulfill];
+  }];
+
+  [self awaitExpectations];
+}
+
+- (void)testUpdateNestedFields {
+  FIRDocumentReference *doc = [self documentRef];
+
+  [self writeDocumentRef:doc
+                    data:@{
+                      @"a" : @{@"b" : @"old"},
+                      @"c" : @{@"d" : @"old"},
+                      @"e" : @{@"f" : @"old"}
+                    }];
+
+  [self updateDocumentRef:doc
+                     data:@{
+                       @"a.b" : @"new",
+                       [[FIRFieldPath alloc] initWithFields:@[ @"c", @"d" ]] : @"new"
+                     }];
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"testUpdateNestedFields"];
+
+  [doc getDocumentWithCompletion:^(FIRDocumentSnapshot *snapshot, NSError *error) {
+    XCTAssertNil(error);
+    XCTAssertEqualObjects(snapshot.data, (@{
+                            @"a" : @{@"b" : @"new"},
+                            @"c" : @{@"d" : @"new"},
+                            @"e" : @{@"f" : @"old"}
+                          }));
+    [expectation fulfill];
+  }];
+
+  [self awaitExpectations];
+}
+
+- (void)testCollectionID {
+  XCTAssertEqualObjects([self.db collectionWithPath:@"foo"].collectionID, @"foo");
+  XCTAssertEqualObjects([self.db collectionWithPath:@"foo/bar/baz"].collectionID, @"baz");
+}
+
+- (void)testDocumentID {
+  XCTAssertEqualObjects([self.db documentWithPath:@"foo/bar"].documentID, @"bar");
+  XCTAssertEqualObjects([self.db documentWithPath:@"foo/bar/baz/qux"].documentID, @"qux");
+}
+
+@end

+ 223 - 0
Firestore/Example/Tests/Integration/API/FIRFieldsTests.m

@@ -0,0 +1,223 @@
+/*
+ * 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;
+
+#import <XCTest/XCTest.h>
+
+#import "Core/FSTFirestoreClient.h"
+
+#import "FSTIntegrationTestCase.h"
+
+@interface FIRFieldsTests : FSTIntegrationTestCase
+@end
+
+@implementation FIRFieldsTests
+
+- (NSDictionary<NSString *, id> *)testNestedDataNumbered:(int)number {
+  return @{
+    @"name" : [NSString stringWithFormat:@"room %d", number],
+    @"metadata" : @{
+      @"createdAt" : @(number),
+      @"deep" : @{@"field" : [NSString stringWithFormat:@"deep-field-%d", number]}
+    }
+  };
+}
+
+- (void)testNestedFieldsCanBeWrittenWithSet {
+  NSDictionary<NSString *, id> *testData = [self testNestedDataNumbered:1];
+
+  FIRDocumentReference *doc = [self documentRef];
+  [self writeDocumentRef:doc data:testData];
+
+  FIRDocumentSnapshot *result = [self readDocumentForRef:doc];
+  XCTAssertEqualObjects(result.data, testData);
+}
+
+- (void)testNestedFieldsCanBeReadDirectly {
+  NSDictionary<NSString *, id> *testData = [self testNestedDataNumbered:1];
+
+  FIRDocumentReference *doc = [self documentRef];
+  [self writeDocumentRef:doc data:testData];
+
+  FIRDocumentSnapshot *result = [self readDocumentForRef:doc];
+  XCTAssertEqualObjects(result[@"name"], testData[@"name"]);
+  XCTAssertEqualObjects(result[@"metadata"], testData[@"metadata"]);
+  XCTAssertEqualObjects(result[@"metadata.deep.field"], testData[@"metadata"][@"deep"][@"field"]);
+  XCTAssertNil(result[@"metadata.nofield"]);
+  XCTAssertNil(result[@"nometadata.nofield"]);
+}
+
+- (void)testNestedFieldsCanBeUpdated {
+  NSDictionary<NSString *, id> *testData = [self testNestedDataNumbered:1];
+
+  FIRDocumentReference *doc = [self documentRef];
+  [self writeDocumentRef:doc data:testData];
+  [self updateDocumentRef:doc data:@{ @"metadata.deep.field" : @100, @"metadata.added" : @200 }];
+
+  FIRDocumentSnapshot *result = [self readDocumentForRef:doc];
+  XCTAssertEqualObjects(
+      result.data, (@{
+        @"name" : @"room 1",
+        @"metadata" : @{@"createdAt" : @1, @"deep" : @{@"field" : @100}, @"added" : @200}
+      }));
+}
+
+- (void)testNestedFieldsCanBeUsedInQueryFilters {
+  NSDictionary<NSString *, NSDictionary<NSString *, id> *> *testDocs = @{
+    @"1" : [self testNestedDataNumbered:300],
+    @"2" : [self testNestedDataNumbered:100],
+    @"3" : [self testNestedDataNumbered:200]
+  };
+
+  // inequality adds implicit sort on field
+  NSArray<NSDictionary<NSString *, id> *> *expected =
+      @[ [self testNestedDataNumbered:200], [self testNestedDataNumbered:300] ];
+  FIRCollectionReference *coll = [self collectionRefWithDocuments:testDocs];
+
+  FIRQuery *q = [coll queryWhereField:@"metadata.createdAt" isGreaterThanOrEqualTo:@200];
+  FIRQuerySnapshot *results = [self readDocumentSetForRef:q];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(results), (expected));
+}
+
+- (void)testNestedFieldsCanBeUsedInOrderBy {
+  NSDictionary<NSString *, NSDictionary<NSString *, id> *> *testDocs = @{
+    @"1" : [self testNestedDataNumbered:300],
+    @"2" : [self testNestedDataNumbered:100],
+    @"3" : [self testNestedDataNumbered:200]
+  };
+  FIRCollectionReference *coll = [self collectionRefWithDocuments:testDocs];
+
+  XCTestExpectation *queryCompletion = [self expectationWithDescription:@"query"];
+  FIRQuery *q = [coll queryOrderedByField:@"metadata.createdAt"];
+  [q getDocumentsWithCompletion:^(FIRQuerySnapshot *results, NSError *error) {
+    XCTAssertNil(error);
+    XCTAssertEqualObjects(FIRQuerySnapshotGetData(results), (@[
+                            [self testNestedDataNumbered:100], [self testNestedDataNumbered:200],
+                            [self testNestedDataNumbered:300]
+                          ]));
+    [queryCompletion fulfill];
+  }];
+  [self awaitExpectations];
+}
+
+/**
+ * Creates test data with special characters in field names. Datastore currently prohibits mixing
+ * nested data with special characters so tests that use this data must be separate.
+ */
+- (NSDictionary<NSString *, id> *)testDottedDataNumbered:(int)number {
+  return @{
+    @"a" : [NSString stringWithFormat:@"field %d", number],
+    @"b.dot" : @(number),
+    @"c\\slash" : @(number)
+  };
+}
+
+- (void)testFieldsWithSpecialCharsCanBeWrittenWithSet {
+  NSDictionary<NSString *, id> *testData = [self testDottedDataNumbered:1];
+
+  FIRDocumentReference *doc = [self documentRef];
+  [self writeDocumentRef:doc data:testData];
+
+  FIRDocumentSnapshot *result = [self readDocumentForRef:doc];
+  XCTAssertEqualObjects(result.data, testData);
+}
+
+- (void)testFieldsWithSpecialCharsCanBeReadDirectly {
+  NSDictionary<NSString *, id> *testData = [self testDottedDataNumbered:1];
+
+  FIRDocumentReference *doc = [self documentRef];
+  [self writeDocumentRef:doc data:testData];
+
+  FIRDocumentSnapshot *result = [self readDocumentForRef:doc];
+  XCTAssertEqualObjects(result[@"a"], testData[@"a"]);
+  XCTAssertEqualObjects(result[[[FIRFieldPath alloc] initWithFields:@[ @"b.dot" ]]],
+                        testData[@"b.dot"]);
+  XCTAssertEqualObjects(result[@"c\\slash"], testData[@"c\\slash"]);
+}
+
+- (void)testFieldsWithSpecialCharsCanBeUpdated {
+  NSDictionary<NSString *, id> *testData = [self testDottedDataNumbered:1];
+
+  FIRDocumentReference *doc = [self documentRef];
+  [self writeDocumentRef:doc data:testData];
+  [self updateDocumentRef:doc
+                     data:@{
+                       [[FIRFieldPath alloc] initWithFields:@[ @"b.dot" ]] : @100,
+                       @"c\\slash" : @200
+                     }];
+
+  FIRDocumentSnapshot *result = [self readDocumentForRef:doc];
+  XCTAssertEqualObjects(result.data, (@{ @"a" : @"field 1", @"b.dot" : @100, @"c\\slash" : @200 }));
+}
+
+- (void)testFieldsWithSpecialCharsCanBeUsedInQueryFilters {
+  NSDictionary<NSString *, NSDictionary<NSString *, id> *> *testDocs = @{
+    @"1" : [self testDottedDataNumbered:300],
+    @"2" : [self testDottedDataNumbered:100],
+    @"3" : [self testDottedDataNumbered:200]
+  };
+
+  // inequality adds implicit sort on field
+  NSArray<NSDictionary<NSString *, id> *> *expected =
+      @[ [self testDottedDataNumbered:200], [self testDottedDataNumbered:300] ];
+  FIRCollectionReference *coll = [self collectionRefWithDocuments:testDocs];
+
+  XCTestExpectation *queryCompletion = [self expectationWithDescription:@"query"];
+  FIRQuery *q = [coll queryWhereFieldPath:[[FIRFieldPath alloc] initWithFields:@[ @"b.dot" ]]
+                   isGreaterThanOrEqualTo:@200];
+  [q getDocumentsWithCompletion:^(FIRQuerySnapshot *results, NSError *error) {
+    XCTAssertNil(error);
+    XCTAssertEqualObjects(FIRQuerySnapshotGetData(results), expected);
+    [queryCompletion fulfill];
+  }];
+
+  [self awaitExpectations];
+}
+
+- (void)testFieldsWithSpecialCharsCanBeUsedInOrderBy {
+  NSDictionary<NSString *, NSDictionary<NSString *, id> *> *testDocs = @{
+    @"1" : [self testDottedDataNumbered:300],
+    @"2" : [self testDottedDataNumbered:100],
+    @"3" : [self testDottedDataNumbered:200]
+  };
+
+  NSArray<NSDictionary<NSString *, id> *> *expected = @[
+    [self testDottedDataNumbered:100], [self testDottedDataNumbered:200],
+    [self testDottedDataNumbered:300]
+  ];
+  FIRCollectionReference *coll = [self collectionRefWithDocuments:testDocs];
+
+  FIRQuery *q = [coll queryOrderedByFieldPath:[[FIRFieldPath alloc] initWithFields:@[ @"b.dot" ]]];
+  XCTestExpectation *queryDot = [self expectationWithDescription:@"query dot"];
+  [q getDocumentsWithCompletion:^(FIRQuerySnapshot *results, NSError *error) {
+    XCTAssertNil(error);
+    XCTAssertEqualObjects(FIRQuerySnapshotGetData(results), expected);
+    [queryDot fulfill];
+  }];
+  [self awaitExpectations];
+
+  XCTestExpectation *querySlash = [self expectationWithDescription:@"query slash"];
+  q = [coll queryOrderedByField:@"c\\slash"];
+  [q getDocumentsWithCompletion:^(FIRQuerySnapshot *results, NSError *error) {
+    XCTAssertNil(error);
+    XCTAssertEqualObjects(FIRQuerySnapshotGetData(results), expected);
+    [querySlash fulfill];
+  }];
+  [self awaitExpectations];
+}
+
+@end

+ 129 - 0
Firestore/Example/Tests/Integration/API/FIRListenerRegistrationTests.m

@@ -0,0 +1,129 @@
+/*
+ * 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;
+
+#import <XCTest/XCTest.h>
+
+#import "FSTIntegrationTestCase.h"
+
+@interface FIRListenerRegistrationTests : FSTIntegrationTestCase
+@end
+
+@implementation FIRListenerRegistrationTests
+
+- (void)testCanBeRemoved {
+  FIRCollectionReference *collectionRef = [self collectionRef];
+  FIRDocumentReference *docRef = [collectionRef documentWithAutoID];
+
+  __block int callbacks = 0;
+  id<FIRListenerRegistration> one = [collectionRef
+      addSnapshotListener:^(FIRQuerySnapshot *_Nullable snapshot, NSError *_Nullable error) {
+        XCTAssertNil(error);
+        callbacks++;
+      }];
+
+  id<FIRListenerRegistration> two = [collectionRef
+      addSnapshotListener:^(FIRQuerySnapshot *_Nullable snapshot, NSError *_Nullable error) {
+        XCTAssertNil(error);
+        callbacks++;
+      }];
+
+  // Wait for initial events
+  [self waitUntil:^BOOL {
+    return callbacks == 2;
+  }];
+
+  // Trigger new events
+  [self writeDocumentRef:docRef data:@{@"foo" : @"bar"}];
+
+  // Write events should have triggered
+  XCTAssertEqual(4, callbacks);
+
+  // No more events should occur
+  [one remove];
+  [two remove];
+
+  [self writeDocumentRef:docRef data:@{@"foo" : @"new-bar"}];
+
+  // Assert no further events occurred
+  XCTAssertEqual(4, callbacks);
+}
+
+- (void)testCanBeRemovedTwice {
+  FIRCollectionReference *collectionRef = [self collectionRef];
+  FIRDocumentReference *docRef = [collectionRef documentWithAutoID];
+
+  id<FIRListenerRegistration> one = [collectionRef
+      addSnapshotListener:^(FIRQuerySnapshot *_Nullable snapshot, NSError *_Nullable error){
+      }];
+  id<FIRListenerRegistration> two = [docRef
+      addSnapshotListener:^(FIRDocumentSnapshot *_Nullable snapshot, NSError *_Nullable error){
+      }];
+
+  [one remove];
+  [one remove];
+
+  [two remove];
+  [two remove];
+}
+
+- (void)testCanBeRemovedIndependently {
+  FIRCollectionReference *collectionRef = [self collectionRef];
+  FIRDocumentReference *docRef = [collectionRef documentWithAutoID];
+
+  __block int callbacksOne = 0;
+  __block int callbacksTwo = 0;
+  id<FIRListenerRegistration> one = [collectionRef
+      addSnapshotListener:^(FIRQuerySnapshot *_Nullable snapshot, NSError *_Nullable error) {
+        XCTAssertNil(error);
+        callbacksOne++;
+      }];
+
+  id<FIRListenerRegistration> two = [collectionRef
+      addSnapshotListener:^(FIRQuerySnapshot *_Nullable snapshot, NSError *_Nullable error) {
+        XCTAssertNil(error);
+        callbacksTwo++;
+      }];
+
+  // Wait for initial events
+  [self waitUntil:^BOOL {
+    return callbacksOne == 1 && callbacksTwo == 1;
+  }];
+
+  // Trigger new events
+  [self writeDocumentRef:docRef data:@{@"foo" : @"bar"}];
+
+  // Write events should have triggered
+  XCTAssertEqual(2, callbacksOne);
+  XCTAssertEqual(2, callbacksTwo);
+
+  // Should leave "two" unaffected
+  [one remove];
+
+  [self writeDocumentRef:docRef data:@{@"foo" : @"new-bar"}];
+
+  // Assert only events for "two" actually occurred
+  XCTAssertEqual(2, callbacksOne);
+  XCTAssertEqual(3, callbacksTwo);
+
+  [self writeDocumentRef:docRef data:@{@"foo" : @"new-bar"}];
+
+  // No more events should occur
+  [two remove];
+}
+
+@end

+ 197 - 0
Firestore/Example/Tests/Integration/API/FIRQueryTests.m

@@ -0,0 +1,197 @@
+/*
+ * 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;
+
+#import <XCTest/XCTest.h>
+
+#import "Core/FSTFirestoreClient.h"
+
+#import "FSTIntegrationTestCase.h"
+
+@interface FIRQueryTests : FSTIntegrationTestCase
+@end
+
+@implementation FIRQueryTests
+
+- (void)testLimitQueries {
+  FIRCollectionReference *collRef = [self collectionRefWithDocuments:@{
+    @"a" : @{@"k" : @"a"},
+    @"b" : @{@"k" : @"b"},
+    @"c" : @{@"k" : @"c"}
+
+  }];
+  FIRQuerySnapshot *snapshot = [self readDocumentSetForRef:[collRef queryLimitedTo:2]];
+
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(snapshot), (@[ @{@"k" : @"a"}, @{@"k" : @"b"} ]));
+}
+
+- (void)testLimitQueriesWithDescendingSortOrder {
+  FIRCollectionReference *collRef = [self collectionRefWithDocuments:@{
+    @"a" : @{@"k" : @"a", @"sort" : @0},
+    @"b" : @{@"k" : @"b", @"sort" : @1},
+    @"c" : @{@"k" : @"c", @"sort" : @1},
+    @"d" : @{@"k" : @"d", @"sort" : @2},
+
+  }];
+  FIRQuerySnapshot *snapshot =
+      [self readDocumentSetForRef:[[collRef queryOrderedByField:@"sort" descending:YES]
+                                      queryLimitedTo:2]];
+
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(snapshot), (@[
+                          @{ @"k" : @"d",
+                             @"sort" : @2 },
+                          @{ @"k" : @"c",
+                             @"sort" : @1 }
+                        ]));
+}
+
+- (void)testKeyOrderIsDescendingForDescendingInequality {
+  FIRCollectionReference *collRef = [self collectionRefWithDocuments:@{
+    @"a" : @{@"foo" : @42},
+    @"b" : @{@"foo" : @42.0},
+    @"c" : @{@"foo" : @42},
+    @"d" : @{@"foo" : @21},
+    @"e" : @{@"foo" : @21.0},
+    @"f" : @{@"foo" : @66},
+    @"g" : @{@"foo" : @66.0},
+  }];
+  FIRQuerySnapshot *snapshot =
+      [self readDocumentSetForRef:[[collRef queryWhereField:@"foo" isGreaterThan:@21]
+                                      queryOrderedByField:@"foo"
+                                               descending:YES]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetIDs(snapshot), (@[ @"g", @"f", @"c", @"b", @"a" ]));
+}
+
+- (void)testUnaryFilterQueries {
+  FIRCollectionReference *collRef = [self collectionRefWithDocuments:@{
+    @"a" : @{@"null" : [NSNull null], @"nan" : @(NAN)},
+    @"b" : @{@"null" : [NSNull null], @"nan" : @0},
+    @"c" : @{@"null" : @NO, @"nan" : @(NAN)}
+  }];
+
+  FIRQuerySnapshot *results =
+      [self readDocumentSetForRef:[[collRef queryWhereField:@"null" isEqualTo:[NSNull null]]
+                                      queryWhereField:@"nan"
+                                            isEqualTo:@(NAN)]];
+
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(results), (@[
+                          @{ @"null" : [NSNull null],
+                             @"nan" : @(NAN) }
+                        ]));
+}
+
+- (void)testQueryWithFieldPaths {
+  FIRCollectionReference *collRef = [self collectionRefWithDocuments:@{
+    @"a" : @{@"a" : @1},
+    @"b" : @{@"a" : @2},
+    @"c" : @{@"a" : @3}
+  }];
+
+  FIRQuery *query =
+      [collRef queryWhereFieldPath:[[FIRFieldPath alloc] initWithFields:@[ @"a" ]] isLessThan:@3];
+  query = [query queryOrderedByFieldPath:[[FIRFieldPath alloc] initWithFields:@[ @"a" ]]
+                              descending:YES];
+
+  FIRQuerySnapshot *snapshot = [self readDocumentSetForRef:query];
+
+  XCTAssertEqualObjects(FIRQuerySnapshotGetIDs(snapshot), (@[ @"b", @"a" ]));
+}
+
+- (void)testFilterOnInfinity {
+  FIRCollectionReference *collRef = [self collectionRefWithDocuments:@{
+    @"a" : @{@"inf" : @(INFINITY)},
+    @"b" : @{@"inf" : @(-INFINITY)}
+  }];
+
+  FIRQuerySnapshot *results =
+      [self readDocumentSetForRef:[collRef queryWhereField:@"inf" isEqualTo:@(INFINITY)]];
+
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(results), (@[ @{ @"inf" : @(INFINITY) } ]));
+}
+
+- (void)testCanExplicitlySortByDocumentID {
+  NSDictionary *testDocs = @{
+    @"a" : @{@"key" : @"a"},
+    @"b" : @{@"key" : @"b"},
+    @"c" : @{@"key" : @"c"},
+  };
+  FIRCollectionReference *collection = [self collectionRefWithDocuments:testDocs];
+
+  // Ideally this would be descending to validate it's different than
+  // the default, but that requires an extra index
+  FIRQuerySnapshot *docs =
+      [self readDocumentSetForRef:[collection queryOrderedByFieldPath:[FIRFieldPath documentID]]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(docs),
+                        (@[ testDocs[@"a"], testDocs[@"b"], testDocs[@"c"] ]));
+}
+
+- (void)testCanQueryByDocumentID {
+  NSDictionary *testDocs = @{
+    @"aa" : @{@"key" : @"aa"},
+    @"ab" : @{@"key" : @"ab"},
+    @"ba" : @{@"key" : @"ba"},
+    @"bb" : @{@"key" : @"bb"},
+  };
+  FIRCollectionReference *collection = [self collectionRefWithDocuments:testDocs];
+  FIRQuerySnapshot *docs =
+      [self readDocumentSetForRef:[collection queryWhereFieldPath:[FIRFieldPath documentID]
+                                                        isEqualTo:@"ab"]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(docs), (@[ testDocs[@"ab"] ]));
+}
+
+- (void)testCanQueryByDocumentIDs {
+  NSDictionary *testDocs = @{
+    @"aa" : @{@"key" : @"aa"},
+    @"ab" : @{@"key" : @"ab"},
+    @"ba" : @{@"key" : @"ba"},
+    @"bb" : @{@"key" : @"bb"},
+  };
+  FIRCollectionReference *collection = [self collectionRefWithDocuments:testDocs];
+  FIRQuerySnapshot *docs =
+      [self readDocumentSetForRef:[collection queryWhereFieldPath:[FIRFieldPath documentID]
+                                                        isEqualTo:@"ab"]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(docs), (@[ testDocs[@"ab"] ]));
+
+  docs = [self readDocumentSetForRef:[[collection queryWhereFieldPath:[FIRFieldPath documentID]
+                                                        isGreaterThan:@"aa"]
+                                         queryWhereFieldPath:[FIRFieldPath documentID]
+                                         isLessThanOrEqualTo:@"ba"]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(docs), (@[ testDocs[@"ab"], testDocs[@"ba"] ]));
+}
+
+- (void)testCanQueryByDocumentIDsUsingRefs {
+  NSDictionary *testDocs = @{
+    @"aa" : @{@"key" : @"aa"},
+    @"ab" : @{@"key" : @"ab"},
+    @"ba" : @{@"key" : @"ba"},
+    @"bb" : @{@"key" : @"bb"},
+  };
+  FIRCollectionReference *collection = [self collectionRefWithDocuments:testDocs];
+  FIRQuerySnapshot *docs = [self
+      readDocumentSetForRef:[collection queryWhereFieldPath:[FIRFieldPath documentID]
+                                                  isEqualTo:[collection documentWithPath:@"ab"]]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(docs), (@[ testDocs[@"ab"] ]));
+
+  docs = [self
+      readDocumentSetForRef:[[collection queryWhereFieldPath:[FIRFieldPath documentID]
+                                               isGreaterThan:[collection documentWithPath:@"aa"]]
+                                queryWhereFieldPath:[FIRFieldPath documentID]
+                                isLessThanOrEqualTo:[collection documentWithPath:@"ba"]]];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(docs), (@[ testDocs[@"ab"], testDocs[@"ba"] ]));
+}
+
+@end

+ 183 - 0
Firestore/Example/Tests/Integration/API/FIRServerTimestampTests.m

@@ -0,0 +1,183 @@
+/*
+ * 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;
+
+#import <XCTest/XCTest.h>
+
+#import "Core/FSTFirestoreClient.h"
+
+#import "FSTEventAccumulator.h"
+#import "FSTIntegrationTestCase.h"
+
+@interface FIRServerTimestampTests : FSTIntegrationTestCase
+@end
+
+@implementation FIRServerTimestampTests {
+  // Data written in tests via set.
+  NSDictionary *_setData;
+
+  // Base and update data used for update tests.
+  NSDictionary *_initialData;
+  NSDictionary *_updateData;
+
+  // A document reference to read and write to.
+  FIRDocumentReference *_docRef;
+
+  // Accumulator used to capture events during the test.
+  FSTEventAccumulator *_accumulator;
+
+  // Listener registration for a listener maintained during the course of the test.
+  id<FIRListenerRegistration> _listenerRegistration;
+}
+
+- (void)setUp {
+  [super setUp];
+
+  // Data written in tests via set.
+  _setData = @{
+    @"a" : @42,
+    @"when" : [FIRFieldValue fieldValueForServerTimestamp],
+    @"deep" : @{@"when" : [FIRFieldValue fieldValueForServerTimestamp]}
+  };
+
+  // Base and update data used for update tests.
+  _initialData = @{ @"a" : @42 };
+  _updateData = @{
+    @"when" : [FIRFieldValue fieldValueForServerTimestamp],
+    @"deep" : @{@"when" : [FIRFieldValue fieldValueForServerTimestamp]}
+  };
+
+  _docRef = [self documentRef];
+  _accumulator = [FSTEventAccumulator accumulatorForTest:self];
+  _listenerRegistration = [_docRef addSnapshotListener:_accumulator.handler];
+
+  // Wait for initial nil snapshot to avoid potential races.
+  FIRDocumentSnapshot *initialSnapshot = [_accumulator awaitEventWithName:@"initial event"];
+  XCTAssertFalse(initialSnapshot.exists);
+}
+
+- (void)tearDown {
+  [_listenerRegistration remove];
+
+  [super tearDown];
+}
+
+// Returns the expected data, with an arbitrary timestamp substituted in.
+- (NSDictionary *)expectedDataWithTimestamp:(id _Nullable)timestamp {
+  return @{ @"a" : @42, @"when" : timestamp, @"deep" : @{@"when" : timestamp} };
+}
+
+/** Writes _initialData and waits for the corresponding snapshot. */
+- (void)writeInitialData {
+  [self writeDocumentRef:_docRef data:_initialData];
+  FIRDocumentSnapshot *initialDataSnap = [_accumulator awaitEventWithName:@"Initial data event."];
+  XCTAssertEqualObjects(initialDataSnap.data, _initialData);
+}
+
+/** Waits for a snapshot containing _setData but with NSNull for the timestamps. */
+- (void)waitForLocalEvent {
+  FIRDocumentSnapshot *localSnap = [_accumulator awaitEventWithName:@"Local event."];
+  XCTAssertEqualObjects(localSnap.data, [self expectedDataWithTimestamp:[NSNull null]]);
+}
+
+/** Waits for a snapshot containing _setData but with resolved server timestamps. */
+- (void)waitForRemoteEvent {
+  // server event should have a resolved timestamp; verify it.
+  FIRDocumentSnapshot *remoteSnap = [_accumulator awaitEventWithName:@"Remote event"];
+  XCTAssertTrue(remoteSnap.exists);
+  NSDate *when = remoteSnap[@"when"];
+  XCTAssertTrue([when isKindOfClass:[NSDate class]]);
+  // Tolerate up to 10 seconds of clock skew between client and server.
+  XCTAssertEqualWithAccuracy(when.timeIntervalSinceNow, 0, 10);
+
+  // Validate the rest of the document.
+  XCTAssertEqualObjects(remoteSnap.data, [self expectedDataWithTimestamp:when]);
+}
+
+- (void)runTransactionBlock:(void (^)(FIRTransaction *transaction))transactionBlock {
+  XCTestExpectation *expectation = [self expectationWithDescription:@"transaction complete"];
+  [_docRef.firestore runTransactionWithBlock:^id(FIRTransaction *transaction, NSError **pError) {
+    transactionBlock(transaction);
+    return nil;
+  }
+      completion:^(id result, NSError *error) {
+        XCTAssertNil(error);
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+}
+
+- (void)testServerTimestampsWorkViaSet {
+  [self writeDocumentRef:_docRef data:_setData];
+  [self waitForLocalEvent];
+  [self waitForRemoteEvent];
+}
+
+- (void)testServerTimestampsWorkViaUpdate {
+  [self writeInitialData];
+  [self updateDocumentRef:_docRef data:_updateData];
+  [self waitForLocalEvent];
+  [self waitForRemoteEvent];
+}
+
+- (void)testServerTimestampsWorkViaTransactionSet {
+  [self runTransactionBlock:^(FIRTransaction *transaction) {
+    [transaction setData:_setData forDocument:_docRef];
+  }];
+
+  [self waitForRemoteEvent];
+}
+
+- (void)testServerTimestampsWorkViaTransactionUpdate {
+  [self writeInitialData];
+  [self runTransactionBlock:^(FIRTransaction *transaction) {
+    [transaction updateData:_updateData forDocument:_docRef];
+  }];
+  [self waitForRemoteEvent];
+}
+
+- (void)testServerTimestampsFailViaUpdateOnNonexistentDocument {
+  XCTestExpectation *expectation = [self expectationWithDescription:@"update complete"];
+  [_docRef updateData:_updateData
+           completion:^(NSError *error) {
+             XCTAssertNotNil(error);
+             XCTAssertEqualObjects(error.domain, FIRFirestoreErrorDomain);
+             XCTAssertEqual(error.code, FIRFirestoreErrorCodeNotFound);
+             [expectation fulfill];
+           }];
+  [self awaitExpectations];
+}
+
+- (void)testServerTimestampsFailViaTransactionUpdateOnNonexistentDocument {
+  XCTestExpectation *expectation = [self expectationWithDescription:@"transaction complete"];
+  [_docRef.firestore runTransactionWithBlock:^id(FIRTransaction *transaction, NSError **pError) {
+    [transaction updateData:_updateData forDocument:_docRef];
+    return nil;
+  }
+      completion:^(id result, NSError *error) {
+        XCTAssertNotNil(error);
+        XCTAssertEqualObjects(error.domain, FIRFirestoreErrorDomain);
+        // TODO(b/35201829): This should be NotFound, but right now we retry transactions on any
+        // error and so this turns into Aborted instead.
+        // TODO(mikelehen): Actually it's FailedPrecondition, unlike Android. What do we want???
+        XCTAssertEqual(error.code, FIRFirestoreErrorCodeFailedPrecondition);
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+}
+
+@end

+ 79 - 0
Firestore/Example/Tests/Integration/API/FIRTypeTests.m

@@ -0,0 +1,79 @@
+/*
+ * 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;
+
+#import <XCTest/XCTest.h>
+
+#import "FSTIntegrationTestCase.h"
+
+@interface FIRTypeTests : FSTIntegrationTestCase
+@end
+
+@implementation FIRTypeTests
+
+- (void)assertSuccessfulRoundtrip:(NSDictionary *)data {
+  FIRDocumentReference *doc = [self.db documentWithPath:@"rooms/eros"];
+
+  [self writeDocumentRef:doc data:data];
+  FIRDocumentSnapshot *document = [self readDocumentForRef:doc];
+  XCTAssertTrue(document.exists);
+  XCTAssertEqualObjects(document.data, data);
+}
+
+- (void)testCanReadAndWriteNullFields {
+  [self assertSuccessfulRoundtrip:@{ @"a" : @1, @"b" : [NSNull null] }];
+}
+
+- (void)testCanReadAndWriteArrayFields {
+  [self assertSuccessfulRoundtrip:@{
+    @"array" : @[ @1, @"foo", @{@"deep" : @YES}, [NSNull null] ]
+  }];
+}
+
+- (void)testCanReadAndWriteBlobFields {
+  NSData *data = [NSData dataWithBytes:"\0\1\2" length:3];
+  [self assertSuccessfulRoundtrip:@{@"blob" : data}];
+}
+
+- (void)testCanReadAndWriteGeoPointFields {
+  [self assertSuccessfulRoundtrip:@{
+    @"geoPoint" : [[FIRGeoPoint alloc] initWithLatitude:1.23 longitude:4.56]
+  }];
+}
+
+- (void)testCanReadAndWriteTimestampFields {
+  // Choose a value that can be converted losslessly between fixed point and double
+  NSDate *timestamp = [NSDate dateWithTimeIntervalSince1970:1491847082.125];
+  [self assertSuccessfulRoundtrip:@{@"timestamp" : timestamp}];
+}
+
+- (void)testCanReadAndWriteDocumentReferences {
+  // We can't use assertSuccessfulRoundtrip since FIRDocumentReference doesn't implement isEqual.
+  FIRDocumentReference *docRef = [self.db documentWithPath:@"rooms/eros"];
+  id data = @{ @"a" : @42, @"ref" : docRef };
+  [self writeDocumentRef:docRef data:data];
+
+  FIRDocumentSnapshot *readDoc = [self readDocumentForRef:docRef];
+  XCTAssertTrue(readDoc.exists);
+
+  XCTAssertEqualObjects(readDoc[@"a"], data[@"a"]);
+  FIRDocumentReference *readDocRef = readDoc[@"ref"];
+  XCTAssertTrue([readDocRef isKindOfClass:[FIRDocumentReference class]]);
+  XCTAssertEqualObjects(readDocRef.path, docRef.path);
+}
+
+@end

+ 560 - 0
Firestore/Example/Tests/Integration/API/FIRValidationTests.m

@@ -0,0 +1,560 @@
+/*
+ * 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;
+
+#import <XCTest/XCTest.h>
+
+#import "FSTHelpers.h"
+#import "FSTIntegrationTestCase.h"
+
+// We have tests for passing nil when nil is not supposed to be allowed. So suppress the warnings.
+#pragma clang diagnostic ignored "-Wnonnull"
+
+@interface FIRValidationTests : FSTIntegrationTestCase
+@end
+
+@implementation FIRValidationTests
+
+#pragma mark - FIRFirestoreSettings Validation
+
+- (void)testNilHostFails {
+  FIRFirestoreSettings *settings = self.db.settings;
+  FSTAssertThrows(settings.host = nil,
+                  @"host setting may not be nil. You should generally just use the default value "
+                   "(which is firestore.googleapis.com)");
+}
+
+- (void)testNilDispatchQueueFails {
+  FIRFirestoreSettings *settings = self.db.settings;
+  FSTAssertThrows(settings.dispatchQueue = nil,
+                  @"dispatch queue setting may not be nil. Create a new dispatch queue with "
+                   "dispatch_queue_create(\"com.example.MyQueue\", NULL) or just use the default "
+                   "(which is the main queue, returned from dispatch_get_main_queue())");
+}
+
+- (void)testChangingSettingsAfterUseFails {
+  FIRFirestoreSettings *settings = self.db.settings;
+  [[self.db documentWithPath:@"foo/bar"] setData:@{ @"a" : @42 }];
+  settings.host = @"example.com";
+  FSTAssertThrows(self.db.settings = settings,
+                  @"Firestore instance has already been started and its settings can no longer be "
+                  @"changed. You can only set settings before calling any other methods on "
+                  @"a Firestore instance.");
+}
+
+#pragma mark - FIRFirestore Validation
+
+- (void)testNilFIRAppFails {
+  FSTAssertThrows(
+      [FIRFirestore firestoreForApp:nil],
+      @"FirebaseApp instance may not be nil. Use FirebaseApp.app() if you'd like to use the "
+       "default FirebaseApp instance.");
+}
+
+// TODO(b/62410906): Test for firestoreForApp:database: with nil DatabaseID.
+
+- (void)testNilTransactionBlocksFail {
+  FSTAssertThrows([self.db runTransactionWithBlock:nil
+                                        completion:^(id result, NSError *error) {
+                                          XCTFail(@"Completion shouldn't run.");
+                                        }],
+                  @"Transaction block cannot be nil.");
+
+  FSTAssertThrows(
+      [self.db runTransactionWithBlock:^id(FIRTransaction *transaction, NSError **pError) {
+        XCTFail(@"Transaction block shouldn't run.");
+        return nil;
+      }
+                            completion:nil],
+      @"Transaction completion block cannot be nil.");
+}
+
+#pragma mark - Collection and Document Path Validation
+
+- (void)testNilCollectionPathsFail {
+  FIRDocumentReference *baseDocRef = [self.db documentWithPath:@"foo/bar"];
+  NSString *nilError = @"Collection path cannot be nil.";
+  FSTAssertThrows([self.db collectionWithPath:nil], nilError);
+  FSTAssertThrows([baseDocRef collectionWithPath:nil], nilError);
+}
+
+- (void)testWrongLengthCollectionPathsFail {
+  FIRDocumentReference *baseDocRef = [self.db documentWithPath:@"foo/bar"];
+  NSArray *badAbsolutePaths = @[ @"foo/bar", @"foo/bar/baz/quu" ];
+  NSArray *badRelativePaths = @[ @"", @"baz/quu" ];
+  NSArray *badPathLengths = @[ @2, @4 ];
+  NSString *errorFormat =
+      @"Invalid collection reference. Collection references must have an odd "
+      @"number of segments, but %@ has %@";
+  for (NSUInteger i = 0; i < badAbsolutePaths.count; i++) {
+    NSString *error =
+        [NSString stringWithFormat:errorFormat, badAbsolutePaths[i], badPathLengths[i]];
+    FSTAssertThrows([self.db collectionWithPath:badAbsolutePaths[i]], error);
+    FSTAssertThrows([baseDocRef collectionWithPath:badRelativePaths[i]], error);
+  }
+}
+
+- (void)testNilDocumentPathsFail {
+  FIRCollectionReference *baseCollectionRef = [self.db collectionWithPath:@"foo"];
+  NSString *nilError = @"Document path cannot be nil.";
+  FSTAssertThrows([self.db documentWithPath:nil], nilError);
+  FSTAssertThrows([baseCollectionRef documentWithPath:nil], nilError);
+}
+
+- (void)testWrongLengthDocumentPathsFail {
+  FIRCollectionReference *baseCollectionRef = [self.db collectionWithPath:@"foo"];
+  NSArray *badAbsolutePaths = @[ @"foo", @"foo/bar/baz" ];
+  NSArray *badRelativePaths = @[ @"", @"bar/baz" ];
+  NSArray *badPathLengths = @[ @1, @3 ];
+  NSString *errorFormat =
+      @"Invalid document reference. Document references must have an even "
+      @"number of segments, but %@ has %@";
+  for (NSUInteger i = 0; i < badAbsolutePaths.count; i++) {
+    NSString *error =
+        [NSString stringWithFormat:errorFormat, badAbsolutePaths[i], badPathLengths[i]];
+    FSTAssertThrows([self.db documentWithPath:badAbsolutePaths[i]], error);
+    FSTAssertThrows([baseCollectionRef documentWithPath:badRelativePaths[i]], error);
+  }
+}
+
+- (void)testPathsWithEmptySegmentsFail {
+  // We're only testing using collectionWithPath since the validation happens in FSTPath which is
+  // shared by all methods that accept paths.
+
+  // leading / trailing slashes are okay.
+  [self.db collectionWithPath:@"/foo/"];
+  [self.db collectionWithPath:@"/foo"];
+  [self.db collectionWithPath:@"foo/"];
+
+  FSTAssertThrows([self.db collectionWithPath:@"foo//bar/baz"],
+                  @"Invalid path (foo//bar/baz). Paths must not contain // in them.");
+  FSTAssertThrows([self.db collectionWithPath:@"//foo"],
+                  @"Invalid path (//foo). Paths must not contain // in them.");
+  FSTAssertThrows([self.db collectionWithPath:@"foo//"],
+                  @"Invalid path (foo//). Paths must not contain // in them.");
+}
+
+#pragma mark - Write Validation
+
+- (void)testWritesWithNonDictionaryValuesFail {
+  NSArray *badData = @[
+    @42, @"test", @[ @1 ], [NSDate date], [NSNull null], [FIRFieldValue fieldValueForDelete],
+    [FIRFieldValue fieldValueForServerTimestamp]
+  ];
+
+  for (id data in badData) {
+    [self expectWrite:data toFailWithReason:@"Data to be written must be an NSDictionary."];
+  }
+}
+
+- (void)testWritesWithNestedArraysFail {
+  [self expectWrite:@{
+    @"nested-array" : @[ @1, @[ @2 ] ]
+  }
+      toFailWithReason:@"Nested arrays are not supported"];
+}
+
+- (void)testWritesWithInvalidTypesFail {
+  [self expectWrite:@{
+    @"foo" : @{@"bar" : self}
+  }
+      toFailWithReason:@"Unsupported type: FIRValidationTests (found in field foo.bar)"];
+}
+
+- (void)testWritesWithLargeNumbersFail {
+  NSNumber *num = @((unsigned long long)LONG_MAX + 1);
+  NSString *reason =
+      [NSString stringWithFormat:@"NSNumber (%@) is too large (found in field num)", num];
+  [self expectWrite:@{@"num" : num} toFailWithReason:reason];
+}
+
+- (void)testWritesWithReferencesToADifferentDatabaseFail {
+  FIRDocumentReference *ref =
+      [[self firestoreWithProjectID:@"different-db"] documentWithPath:@"baz/quu"];
+  id data = @{@"foo" : ref};
+  [self expectWrite:data
+      toFailWithReason:
+          [NSString
+              stringWithFormat:@"Document Reference is for database different-db/(default) but "
+                                "should be for database %@/(default) (found in field foo)",
+                               [FSTIntegrationTestCase projectID]]];
+}
+
+- (void)testWritesWithReservedFieldsFail {
+  [self expectWrite:@{
+    @"__baz__" : @1
+  }
+      toFailWithReason:@"Document fields cannot begin and end with __ (found in field __baz__)"];
+  [self expectWrite:@{
+    @"foo" : @{@"__baz__" : @1}
+  }
+      toFailWithReason:
+          @"Document fields cannot begin and end with __ (found in field foo.__baz__)"];
+  [self expectWrite:@{
+    @"__baz__" : @{@"foo" : @1}
+  }
+      toFailWithReason:@"Document fields cannot begin and end with __ (found in field __baz__)"];
+
+  [self expectUpdate:@{
+    @"foo.__baz__" : @1
+  }
+      toFailWithReason:
+          @"Document fields cannot begin and end with __ (found in field foo.__baz__)"];
+  [self expectUpdate:@{
+    @"__baz__.foo" : @1
+  }
+      toFailWithReason:
+          @"Document fields cannot begin and end with __ (found in field __baz__.foo)"];
+  [self expectUpdate:@{
+    @1 : @1
+  }
+      toFailWithReason:@"Dictionary keys in updateData: must be NSStrings or FIRFieldPaths."];
+}
+
+- (void)testSetsWithFieldValueDeleteFail {
+  [self expectSet:@{@"foo" : [FIRFieldValue fieldValueForDelete]}
+      toFailWithReason:@"FieldValue.delete() can only be used with updateData()."];
+}
+
+- (void)testUpdatesWithNestedFieldValueDeleteFail {
+  [self expectUpdate:@{
+    @"foo" : @{@"bar" : [FIRFieldValue fieldValueForDelete]}
+  }
+      toFailWithReason:
+          @"FieldValue.delete() can only appear at the top level of your update data "
+           "(found in field foo.bar)"];
+}
+
+- (void)testBatchWritesWithIncorrectReferencesFail {
+  FIRFirestore *db1 = [self firestore];
+  FIRFirestore *db2 = [self firestore];
+  XCTAssertNotEqual(db1, db2);
+
+  NSString *reason = @"Provided document reference is from a different Firestore instance.";
+  id data = @{ @"foo" : @1 };
+  FIRDocumentReference *badRef = [db2 documentWithPath:@"foo/bar"];
+  FIRWriteBatch *batch = [db1 batch];
+  FSTAssertThrows([batch setData:data forDocument:badRef], reason);
+  FSTAssertThrows([batch setData:data forDocument:badRef options:[FIRSetOptions merge]], reason);
+  FSTAssertThrows([batch updateData:data forDocument:badRef], reason);
+  FSTAssertThrows([batch deleteDocument:badRef], reason);
+}
+
+- (void)testTransactionWritesWithIncorrectReferencesFail {
+  FIRFirestore *db1 = [self firestore];
+  FIRFirestore *db2 = [self firestore];
+  XCTAssertNotEqual(db1, db2);
+
+  NSString *reason = @"Provided document reference is from a different Firestore instance.";
+  id data = @{ @"foo" : @1 };
+  FIRDocumentReference *badRef = [db2 documentWithPath:@"foo/bar"];
+
+  XCTestExpectation *transactionDone = [self expectationWithDescription:@"transaction done"];
+  [db1 runTransactionWithBlock:^id(FIRTransaction *txn, NSError **pError) {
+    FSTAssertThrows([txn getDocument:badRef error:nil], reason);
+    FSTAssertThrows([txn setData:data forDocument:badRef], reason);
+    FSTAssertThrows([txn setData:data forDocument:badRef options:[FIRSetOptions merge]], reason);
+    FSTAssertThrows([txn updateData:data forDocument:badRef], reason);
+    FSTAssertThrows([txn deleteDocument:badRef], reason);
+    return nil;
+  }
+      completion:^(id result, NSError *error) {
+        // ends up being a no-op transaction.
+        XCTAssertNil(error);
+        [transactionDone fulfill];
+      }];
+  [self awaitExpectations];
+}
+
+#pragma mark - Field Path validation
+// TODO(b/37244157): More validation for invalid field paths.
+
+- (void)testFieldPathsWithEmptySegmentsFail {
+  NSArray *badFieldPaths = @[ @"", @"foo..baz", @".foo", @"foo." ];
+
+  for (NSString *fieldPath in badFieldPaths) {
+    NSString *reason =
+        [NSString stringWithFormat:
+                      @"Invalid field path (%@). Paths must not be empty, begin with "
+                      @"'.', end with '.', or contain '..'",
+                      fieldPath];
+    [self expectFieldPath:fieldPath toFailWithReason:reason];
+  }
+}
+
+- (void)testFieldPathsWithInvalidSegmentsFail {
+  NSArray *badFieldPaths = @[ @"foo~bar", @"foo*bar", @"foo/bar", @"foo[1", @"foo]1", @"foo[1]" ];
+
+  for (NSString *fieldPath in badFieldPaths) {
+    NSString *reason =
+        [NSString stringWithFormat:
+                      @"Invalid field path (%@). Paths must not contain '~', '*', '/', '[', or ']'",
+                      fieldPath];
+    [self expectFieldPath:fieldPath toFailWithReason:reason];
+  }
+}
+
+#pragma mark - Query Validation
+
+- (void)testQueryWithNonPositiveLimitFails {
+  FSTAssertThrows([[self collectionRef] queryLimitedTo:0],
+                  @"Invalid Query. Query limit (0) is invalid. Limit must be positive.");
+  FSTAssertThrows([[self collectionRef] queryLimitedTo:-1],
+                  @"Invalid Query. Query limit (-1) is invalid. Limit must be positive.");
+}
+
+- (void)testQueryInequalityOnNullOrNaNFails {
+  FSTAssertThrows([[self collectionRef] queryWhereField:@"a" isGreaterThan:nil],
+                  @"Invalid Query. You can only perform equality comparisons on nil / NSNull.");
+  FSTAssertThrows([[self collectionRef] queryWhereField:@"a" isGreaterThan:[NSNull null]],
+                  @"Invalid Query. You can only perform equality comparisons on nil / NSNull.");
+
+  FSTAssertThrows([[self collectionRef] queryWhereField:@"a" isGreaterThan:@(NAN)],
+                  @"Invalid Query. You can only perform equality comparisons on NaN.");
+}
+
+- (void)testQueryCannotBeCreatedFromDocumentsMissingSortValues {
+  FIRCollectionReference *testCollection = [self collectionRefWithDocuments:@{
+    @"f" : @{@"v" : @"f", @"nosort" : @1.0}
+  }];
+
+  FIRQuery *query = [testCollection queryOrderedByField:@"sort"];
+  FIRDocumentSnapshot *snapshot = [self readDocumentForRef:[testCollection documentWithPath:@"f"]];
+  XCTAssertTrue(snapshot.exists);
+
+  NSString *reason =
+      @"Invalid query. You are trying to start or end a query using a document for "
+       "which the field 'sort' (used as the order by) does not exist.";
+  FSTAssertThrows([query queryStartingAtDocument:snapshot], reason);
+  FSTAssertThrows([query queryStartingAfterDocument:snapshot], reason);
+  FSTAssertThrows([query queryEndingBeforeDocument:snapshot], reason);
+  FSTAssertThrows([query queryEndingAtDocument:snapshot], reason);
+}
+
+- (void)testQueryBoundMustNotHaveMoreComponentsThanSortOrders {
+  FIRCollectionReference *testCollection = [self collectionRef];
+  FIRQuery *query = [testCollection queryOrderedByField:@"foo"];
+
+  NSString *reason =
+      @"Invalid query. You are trying to start or end a query using more values "
+       "than were specified in the order by.";
+  // More elements than order by
+  FSTAssertThrows(([query queryStartingAtValues:@[ @1, @2 ]]), reason);
+  FSTAssertThrows(([[query queryOrderedByField:@"bar"] queryStartingAtValues:@[ @1, @2, @3 ]]),
+                  reason);
+}
+
+- (void)testQueryOrderedByKeyBoundMustBeAStringWithoutSlashes {
+  FIRCollectionReference *testCollection = [self collectionRef];
+  FIRQuery *query = [testCollection queryOrderedByFieldPath:[FIRFieldPath documentID]];
+  FSTAssertThrows([query queryStartingAtValues:@[ @1 ]],
+                  @"Invalid query. Expected a string for the document ID.");
+  FSTAssertThrows([query queryStartingAtValues:@[ @"foo/bar" ]],
+                  @"Invalid query. Document ID 'foo/bar' contains a slash.");
+}
+
+- (void)testQueryMustNotSpecifyStartingOrEndingPointAfterOrder {
+  FIRCollectionReference *testCollection = [self collectionRef];
+  FIRQuery *query = [testCollection queryOrderedByField:@"foo"];
+  NSString *reason =
+      @"Invalid query. You must not specify a starting point before specifying the order by.";
+  FSTAssertThrows([[query queryStartingAtValues:@[ @1 ]] queryOrderedByField:@"bar"], reason);
+  FSTAssertThrows([[query queryStartingAfterValues:@[ @1 ]] queryOrderedByField:@"bar"], reason);
+  reason = @"Invalid query. You must not specify an ending point before specifying the order by.";
+  FSTAssertThrows([[query queryEndingAtValues:@[ @1 ]] queryOrderedByField:@"bar"], reason);
+  FSTAssertThrows([[query queryEndingBeforeValues:@[ @1 ]] queryOrderedByField:@"bar"], reason);
+}
+
+- (void)testQueriesFilteredByDocumentIDMustUseStringsOrDocumentReferences {
+  FIRCollectionReference *collection = [self collectionRef];
+  NSString *reason =
+      @"Invalid query. When querying by document ID you must provide a valid "
+       "document ID, but it was an empty string.";
+  FSTAssertThrows([collection queryWhereFieldPath:[FIRFieldPath documentID] isEqualTo:@""], reason);
+
+  reason =
+      @"Invalid query. When querying by document ID you must provide a valid document ID, "
+       "but 'foo/bar/baz' contains a '/' character.";
+  FSTAssertThrows(
+      [collection queryWhereFieldPath:[FIRFieldPath documentID] isEqualTo:@"foo/bar/baz"], reason);
+
+  reason =
+      @"Invalid query. When querying by document ID you must provide a valid string or "
+       "DocumentReference, but it was of type: __NSCFNumber";
+  FSTAssertThrows([collection queryWhereFieldPath:[FIRFieldPath documentID] isEqualTo:@1], reason);
+}
+
+- (void)testQueryInequalityFieldMustMatchFirstOrderByField {
+  FIRCollectionReference *coll = [self.db collectionWithPath:@"collection"];
+  FIRQuery *base = [coll queryWhereField:@"x" isGreaterThanOrEqualTo:@32];
+
+  FSTAssertThrows([base queryWhereField:@"y" isLessThan:@"cat"],
+                  @"Invalid Query. All where filters with an inequality (lessThan, "
+                   "lessThanOrEqual, greaterThan, or greaterThanOrEqual) must be on the same "
+                   "field. But you have inequality filters on 'x' and 'y'");
+
+  NSString *reason =
+      @"Invalid query. You have a where filter with "
+       "an inequality (lessThan, lessThanOrEqual, greaterThan, or greaterThanOrEqual) "
+       "on field 'x' and so you must also use 'x' as your first queryOrderedBy field, "
+       "but your first queryOrderedBy is currently on field 'y' instead.";
+  FSTAssertThrows([base queryOrderedByField:@"y"], reason);
+  FSTAssertThrows([[coll queryOrderedByField:@"y"] queryWhereField:@"x" isGreaterThan:@32], reason);
+  FSTAssertThrows([[base queryOrderedByField:@"y"] queryOrderedByField:@"x"], reason);
+  FSTAssertThrows([[[coll queryOrderedByField:@"y"] queryOrderedByField:@"x"] queryWhereField:@"x"
+                                                                                isGreaterThan:@32],
+                  reason);
+
+  XCTAssertNoThrow([base queryWhereField:@"x" isLessThanOrEqualTo:@"cat"],
+                   @"Same inequality fields work");
+
+  XCTAssertNoThrow([base queryWhereField:@"y" isEqualTo:@"cat"],
+                   @"Inequality and equality on different fields works");
+
+  XCTAssertNoThrow([base queryOrderedByField:@"x"], @"inequality same as order by works");
+  XCTAssertNoThrow([[coll queryOrderedByField:@"x"] queryWhereField:@"x" isGreaterThan:@32],
+                   @"inequality same as order by works");
+  XCTAssertNoThrow([[base queryOrderedByField:@"x"] queryOrderedByField:@"y"],
+                   @"inequality same as first order by works.");
+  XCTAssertNoThrow([[[coll queryOrderedByField:@"x"] queryOrderedByField:@"y"] queryWhereField:@"x"
+                                                                                 isGreaterThan:@32],
+                   @"inequality same as first order by works.");
+}
+
+#pragma mark - GeoPoint Validation
+
+- (void)testInvalidGeoPointParameters {
+  [self verifyExceptionForInvalidLatitude:NAN];
+  [self verifyExceptionForInvalidLatitude:-INFINITY];
+  [self verifyExceptionForInvalidLatitude:INFINITY];
+  [self verifyExceptionForInvalidLatitude:-90.1];
+  [self verifyExceptionForInvalidLatitude:90.1];
+
+  [self verifyExceptionForInvalidLongitude:NAN];
+  [self verifyExceptionForInvalidLongitude:-INFINITY];
+  [self verifyExceptionForInvalidLongitude:INFINITY];
+  [self verifyExceptionForInvalidLongitude:-180.1];
+  [self verifyExceptionForInvalidLongitude:180.1];
+}
+
+#pragma mark - Helpers
+
+/** Performs a write using each write API and makes sure it fails with the expected reason. */
+- (void)expectWrite:(id)data toFailWithReason:(NSString *)reason {
+  [self expectWrite:data toFailWithReason:reason includeSets:YES includeUpdates:YES];
+}
+
+/** Performs a write using each set API and makes sure it fails with the expected reason. */
+- (void)expectSet:(id)data toFailWithReason:(NSString *)reason {
+  [self expectWrite:data toFailWithReason:reason includeSets:YES includeUpdates:NO];
+}
+
+/** Performs a write using each update API and makes sure it fails with the expected reason. */
+- (void)expectUpdate:(id)data toFailWithReason:(NSString *)reason {
+  [self expectWrite:data toFailWithReason:reason includeSets:NO includeUpdates:YES];
+}
+
+/**
+ * Performs a write using each set and/or update API and makes sure it fails with the expected
+ * reason.
+ */
+- (void)expectWrite:(id)data
+    toFailWithReason:(NSString *)reason
+         includeSets:(BOOL)includeSets
+      includeUpdates:(BOOL)includeUpdates {
+  FIRDocumentReference *ref = [self documentRef];
+  if (includeSets) {
+    FSTAssertThrows([ref setData:data], reason, @"for %@", data);
+    FSTAssertThrows([[ref.firestore batch] setData:data forDocument:ref], reason, @"for %@", data);
+  }
+
+  if (includeUpdates) {
+    FSTAssertThrows([ref updateData:data], reason, @"for %@", data);
+    FSTAssertThrows([[ref.firestore batch] updateData:data forDocument:ref], reason, @"for %@",
+                    data);
+  }
+
+  XCTestExpectation *transactionDone = [self expectationWithDescription:@"transaction done"];
+  [ref.firestore runTransactionWithBlock:^id(FIRTransaction *transaction, NSError **pError) {
+    if (includeSets) {
+      FSTAssertThrows([transaction setData:data forDocument:ref], reason, @"for %@", data);
+    }
+    if (includeUpdates) {
+      FSTAssertThrows([transaction updateData:data forDocument:ref], reason, @"for %@", data);
+    }
+    return nil;
+  }
+      completion:^(id result, NSError *error) {
+        // ends up being a no-op transaction.
+        XCTAssertNil(error);
+        [transactionDone fulfill];
+      }];
+  [self awaitExpectations];
+}
+
+- (void)testFieldNamesMustNotBeEmpty {
+  NSString *reason = @"Invalid field path. Provided names must not be empty.";
+  FSTAssertThrows([[FIRFieldPath alloc] initWithFields:@[]], reason);
+
+  reason = @"Invalid field name at index 0. Field names must not be empty.";
+  FSTAssertThrows([[FIRFieldPath alloc] initWithFields:@[ @"" ]], reason);
+
+  reason = @"Invalid field name at index 1. Field names must not be empty.";
+  FSTAssertThrows(([[FIRFieldPath alloc] initWithFields:@[ @"foo", @"" ]]), reason);
+}
+
+/**
+ * Tests a field path with all of our APIs that accept field paths and ensures they fail with the
+ * specified reason.
+ */
+- (void)expectFieldPath:(NSString *)fieldPath toFailWithReason:(NSString *)reason {
+  // Get an arbitrary snapshot we can use for testing.
+  FIRDocumentReference *docRef = [self documentRef];
+  [self writeDocumentRef:docRef data:@{ @"test" : @1 }];
+  FIRDocumentSnapshot *snapshot = [self readDocumentForRef:docRef];
+
+  // Update paths.
+  NSMutableDictionary *dict = [NSMutableDictionary dictionary];
+  dict[fieldPath] = @1;
+  [self expectUpdate:dict toFailWithReason:reason];
+
+  // Snapshot fields.
+  FSTAssertThrows(snapshot[fieldPath], reason);
+
+  // Query filter / order fields.
+  FIRCollectionReference *collection = [self collectionRef];
+  FSTAssertThrows([collection queryWhereField:fieldPath isEqualTo:@1], reason);
+  // isLessThan, etc. omitted for brevity since the code path is trivially shared.
+  FSTAssertThrows([collection queryOrderedByField:fieldPath], reason);
+}
+
+- (void)verifyExceptionForInvalidLatitude:(double)latitude {
+  NSString *reason = [NSString
+      stringWithFormat:@"GeoPoint requires a latitude value in the range of [-90, 90], but was %f",
+                       latitude];
+  FSTAssertThrows([[FIRGeoPoint alloc] initWithLatitude:latitude longitude:0], reason);
+}
+
+- (void)verifyExceptionForInvalidLongitude:(double)longitude {
+  NSString *reason =
+      [NSString stringWithFormat:
+                    @"GeoPoint requires a longitude value in the range of [-180, 180], but was %f",
+                    longitude];
+  FSTAssertThrows([[FIRGeoPoint alloc] initWithLatitude:0 longitude:longitude], reason);
+}
+
+@end

+ 313 - 0
Firestore/Example/Tests/Integration/API/FIRWriteBatchTests.m

@@ -0,0 +1,313 @@
+/*
+ * 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;
+
+#import <XCTest/XCTest.h>
+
+#import "FSTEventAccumulator.h"
+#import "FSTIntegrationTestCase.h"
+
+@interface FIRWriteBatchTests : FSTIntegrationTestCase
+@end
+
+@implementation FIRWriteBatchTests
+
+- (void)testSupportEmptyBatches {
+  XCTestExpectation *expectation = [self expectationWithDescription:@"batch written"];
+  [[[self firestore] batch] commitWithCompletion:^(NSError *error) {
+    XCTAssertNil(error);
+    [expectation fulfill];
+  }];
+  [self awaitExpectations];
+}
+
+- (void)testSetDocuments {
+  FIRDocumentReference *doc = [self documentRef];
+  XCTestExpectation *batchExpectation = [self expectationWithDescription:@"batch written"];
+  FIRWriteBatch *batch = [doc.firestore batch];
+  [batch setData:@{@"a" : @"b"} forDocument:doc];
+  [batch setData:@{@"c" : @"d"} forDocument:doc];
+  [batch commitWithCompletion:^(NSError *error) {
+    XCTAssertNil(error);
+    [batchExpectation fulfill];
+  }];
+  [self awaitExpectations];
+  FIRDocumentSnapshot *snapshot = [self readDocumentForRef:doc];
+  XCTAssertTrue(snapshot.exists);
+  XCTAssertEqualObjects(snapshot.data, @{@"c" : @"d"});
+}
+
+- (void)testSetDocumentWithMerge {
+  FIRDocumentReference *doc = [self documentRef];
+  XCTestExpectation *batchExpectation = [self expectationWithDescription:@"batch written"];
+  FIRWriteBatch *batch = [doc.firestore batch];
+  [batch setData:@{ @"a" : @"b", @"nested" : @{@"a" : @"b"} } forDocument:doc];
+  [batch setData:@{
+    @"c" : @"d",
+    @"nested" : @{@"c" : @"d"}
+  }
+      forDocument:doc
+          options:[FIRSetOptions merge]];
+  [batch commitWithCompletion:^(NSError *error) {
+    XCTAssertNil(error);
+    [batchExpectation fulfill];
+  }];
+  [self awaitExpectations];
+  FIRDocumentSnapshot *snapshot = [self readDocumentForRef:doc];
+  XCTAssertTrue(snapshot.exists);
+  XCTAssertEqualObjects(
+      snapshot.data, (
+                         @{ @"a" : @"b",
+                            @"c" : @"d",
+                            @"nested" : @{@"a" : @"b", @"c" : @"d"} }));
+}
+
+- (void)testUpdateDocuments {
+  FIRDocumentReference *doc = [self documentRef];
+  [self writeDocumentRef:doc data:@{@"foo" : @"bar"}];
+  XCTestExpectation *batchExpectation = [self expectationWithDescription:@"batch written"];
+  FIRWriteBatch *batch = [doc.firestore batch];
+  [batch updateData:@{ @"baz" : @42 } forDocument:doc];
+  [batch commitWithCompletion:^(NSError *error) {
+    XCTAssertNil(error);
+    [batchExpectation fulfill];
+  }];
+  [self awaitExpectations];
+  FIRDocumentSnapshot *snapshot = [self readDocumentForRef:doc];
+  XCTAssertTrue(snapshot.exists);
+  XCTAssertEqualObjects(snapshot.data, (@{ @"foo" : @"bar", @"baz" : @42 }));
+}
+
+- (void)testCannotUpdateNonexistentDocuments {
+  FIRDocumentReference *doc = [self documentRef];
+  XCTestExpectation *batchExpectation = [self expectationWithDescription:@"batch written"];
+  FIRWriteBatch *batch = [doc.firestore batch];
+  [batch updateData:@{ @"baz" : @42 } forDocument:doc];
+  [batch commitWithCompletion:^(NSError *error) {
+    XCTAssertNotNil(error);
+    [batchExpectation fulfill];
+  }];
+  [self awaitExpectations];
+  FIRDocumentSnapshot *result = [self readDocumentForRef:doc];
+  XCTAssertFalse(result.exists);
+}
+
+- (void)testDeleteDocuments {
+  FIRDocumentReference *doc = [self documentRef];
+  [self writeDocumentRef:doc data:@{@"foo" : @"bar"}];
+  FIRDocumentSnapshot *snapshot = [self readDocumentForRef:doc];
+
+  XCTAssertTrue(snapshot.exists);
+  XCTestExpectation *batchExpectation = [self expectationWithDescription:@"batch written"];
+  FIRWriteBatch *batch = [doc.firestore batch];
+  [batch deleteDocument:doc];
+  [batch commitWithCompletion:^(NSError *error) {
+    XCTAssertNil(error);
+    [batchExpectation fulfill];
+  }];
+  [self awaitExpectations];
+  snapshot = [self readDocumentForRef:doc];
+  XCTAssertFalse(snapshot.exists);
+}
+
+- (void)testBatchesCommitAtomicallyRaisingCorrectEvents {
+  FIRCollectionReference *collection = [self collectionRef];
+  FIRDocumentReference *docA = [collection documentWithPath:@"a"];
+  FIRDocumentReference *docB = [collection documentWithPath:@"b"];
+  FSTEventAccumulator *accumulator = [FSTEventAccumulator accumulatorForTest:self];
+  [collection addSnapshotListenerWithOptions:[[FIRQueryListenOptions options]
+                                                 includeQueryMetadataChanges:YES]
+                                    listener:accumulator.handler];
+  FIRQuerySnapshot *initialSnap = [accumulator awaitEventWithName:@"initial event"];
+  XCTAssertEqual(initialSnap.count, 0);
+
+  // Atomically write two documents.
+  XCTestExpectation *expectation = [self expectationWithDescription:@"batch written"];
+  FIRWriteBatch *batch = [collection.firestore batch];
+  [batch setData:@{ @"a" : @1 } forDocument:docA];
+  [batch setData:@{ @"b" : @2 } forDocument:docB];
+  [batch commitWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNil(error);
+    [expectation fulfill];
+  }];
+
+  FIRQuerySnapshot *localSnap = [accumulator awaitEventWithName:@"local event"];
+  XCTAssertTrue(localSnap.metadata.hasPendingWrites);
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(localSnap), (@[ @{ @"a" : @1 }, @{ @"b" : @2 } ]));
+
+  FIRQuerySnapshot *serverSnap = [accumulator awaitEventWithName:@"server event"];
+  XCTAssertFalse(serverSnap.metadata.hasPendingWrites);
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(serverSnap), (@[ @{ @"a" : @1 }, @{ @"b" : @2 } ]));
+}
+
+- (void)testBatchesFailAtomicallyRaisingCorrectEvents {
+  FIRCollectionReference *collection = [self collectionRef];
+  FIRDocumentReference *docA = [collection documentWithPath:@"a"];
+  FIRDocumentReference *docB = [collection documentWithPath:@"b"];
+  FSTEventAccumulator *accumulator = [FSTEventAccumulator accumulatorForTest:self];
+  [collection addSnapshotListenerWithOptions:[[FIRQueryListenOptions options]
+                                                 includeQueryMetadataChanges:YES]
+                                    listener:accumulator.handler];
+  FIRQuerySnapshot *initialSnap = [accumulator awaitEventWithName:@"initial event"];
+  XCTAssertEqual(initialSnap.count, 0);
+
+  // Atomically write 1 document and update a nonexistent document.
+  XCTestExpectation *expectation = [self expectationWithDescription:@"batch failed"];
+  FIRWriteBatch *batch = [collection.firestore batch];
+  [batch setData:@{ @"a" : @1 } forDocument:docA];
+  [batch updateData:@{ @"b" : @2 } forDocument:docB];
+  [batch commitWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNotNil(error);
+    XCTAssertEqualObjects(error.domain, FIRFirestoreErrorDomain);
+    XCTAssertEqual(error.code, FIRFirestoreErrorCodeNotFound);
+    [expectation fulfill];
+  }];
+
+  // Local event with the set document.
+  FIRQuerySnapshot *localSnap = [accumulator awaitEventWithName:@"local event"];
+  XCTAssertTrue(localSnap.metadata.hasPendingWrites);
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(localSnap), (@[ @{ @"a" : @1 } ]));
+
+  // Server event with the set reverted.
+  FIRQuerySnapshot *serverSnap = [accumulator awaitEventWithName:@"server event"];
+  XCTAssertFalse(serverSnap.metadata.hasPendingWrites);
+  XCTAssertEqual(serverSnap.count, 0);
+}
+
+- (void)testWriteTheSameServerTimestampAcrossWrites {
+  FIRCollectionReference *collection = [self collectionRef];
+  FIRDocumentReference *docA = [collection documentWithPath:@"a"];
+  FIRDocumentReference *docB = [collection documentWithPath:@"b"];
+  FSTEventAccumulator *accumulator = [FSTEventAccumulator accumulatorForTest:self];
+  [collection addSnapshotListenerWithOptions:[[FIRQueryListenOptions options]
+                                                 includeQueryMetadataChanges:YES]
+                                    listener:accumulator.handler];
+  FIRQuerySnapshot *initialSnap = [accumulator awaitEventWithName:@"initial event"];
+  XCTAssertEqual(initialSnap.count, 0);
+
+  // Atomically write 2 documents with server timestamps.
+  XCTestExpectation *expectation = [self expectationWithDescription:@"batch written"];
+  FIRWriteBatch *batch = [collection.firestore batch];
+  [batch setData:@{@"when" : [FIRFieldValue fieldValueForServerTimestamp]} forDocument:docA];
+  [batch setData:@{@"when" : [FIRFieldValue fieldValueForServerTimestamp]} forDocument:docB];
+  [batch commitWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNil(error);
+    [expectation fulfill];
+  }];
+
+  FIRQuerySnapshot *localSnap = [accumulator awaitEventWithName:@"local event"];
+  XCTAssertTrue(localSnap.metadata.hasPendingWrites);
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(localSnap),
+                        (@[ @{@"when" : [NSNull null]}, @{@"when" : [NSNull null]} ]));
+
+  FIRQuerySnapshot *serverSnap = [accumulator awaitEventWithName:@"server event"];
+  XCTAssertFalse(serverSnap.metadata.hasPendingWrites);
+  XCTAssertEqual(serverSnap.count, 2);
+  NSDate *when = serverSnap.documents[0][@"when"];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(serverSnap),
+                        (@[ @{@"when" : when}, @{@"when" : when} ]));
+}
+
+- (void)testCanWriteTheSameDocumentMultipleTimes {
+  FIRDocumentReference *doc = [self documentRef];
+  FSTEventAccumulator *accumulator = [FSTEventAccumulator accumulatorForTest:self];
+  [doc
+      addSnapshotListenerWithOptions:[[FIRDocumentListenOptions options] includeMetadataChanges:YES]
+                            listener:accumulator.handler];
+  FIRDocumentSnapshot *initialSnap = [accumulator awaitEventWithName:@"initial event"];
+  XCTAssertFalse(initialSnap.exists);
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"batch written"];
+  FIRWriteBatch *batch = [doc.firestore batch];
+  [batch deleteDocument:doc];
+  [batch setData:@{ @"a" : @1, @"b" : @1, @"when" : @"when" } forDocument:doc];
+  [batch updateData:@{
+    @"b" : @2,
+    @"when" : [FIRFieldValue fieldValueForServerTimestamp]
+  }
+        forDocument:doc];
+  [batch commitWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNil(error);
+    [expectation fulfill];
+  }];
+
+  FIRDocumentSnapshot *localSnap = [accumulator awaitEventWithName:@"local event"];
+  XCTAssertTrue(localSnap.metadata.hasPendingWrites);
+  XCTAssertEqualObjects(localSnap.data, (@{ @"a" : @1, @"b" : @2, @"when" : [NSNull null] }));
+
+  FIRDocumentSnapshot *serverSnap = [accumulator awaitEventWithName:@"server event"];
+  XCTAssertFalse(serverSnap.metadata.hasPendingWrites);
+  NSDate *when = serverSnap[@"when"];
+  XCTAssertEqualObjects(serverSnap.data, (@{ @"a" : @1, @"b" : @2, @"when" : when }));
+}
+
+- (void)testUpdateFieldsWithDots {
+  FIRDocumentReference *doc = [self documentRef];
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"testUpdateFieldsWithDots"];
+  FIRWriteBatch *batch = [doc.firestore batch];
+  [batch setData:@{@"a.b" : @"old", @"c.d" : @"old"} forDocument:doc];
+  [batch updateData:@{
+    [[FIRFieldPath alloc] initWithFields:@[ @"a.b" ]] : @"new"
+  }
+        forDocument:doc];
+
+  [batch commitWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNil(error);
+    [doc getDocumentWithCompletion:^(FIRDocumentSnapshot *snapshot, NSError *error) {
+      XCTAssertNil(error);
+      XCTAssertEqualObjects(snapshot.data, (@{@"a.b" : @"new", @"c.d" : @"old"}));
+    }];
+    [expectation fulfill];
+  }];
+
+  [self awaitExpectations];
+}
+
+- (void)testUpdateNestedFields {
+  FIRDocumentReference *doc = [self documentRef];
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"testUpdateNestedFields"];
+  FIRWriteBatch *batch = [doc.firestore batch];
+  [batch setData:@{
+    @"a" : @{@"b" : @"old"},
+    @"c" : @{@"d" : @"old"},
+    @"e" : @{@"f" : @"old"}
+  }
+      forDocument:doc];
+  [batch updateData:@{
+    @"a.b" : @"new",
+    [[FIRFieldPath alloc] initWithFields:@[ @"c", @"d" ]] : @"new"
+  }
+        forDocument:doc];
+  [batch commitWithCompletion:^(NSError *_Nullable error) {
+    XCTAssertNil(error);
+    [doc getDocumentWithCompletion:^(FIRDocumentSnapshot *snapshot, NSError *error) {
+      XCTAssertNil(error);
+      XCTAssertEqualObjects(snapshot.data, (@{
+                              @"a" : @{@"b" : @"new"},
+                              @"c" : @{@"d" : @"new"},
+                              @"e" : @{@"f" : @"old"}
+                            }));
+    }];
+    [expectation fulfill];
+  }];
+
+  [self awaitExpectations];
+}
+
+@end

+ 0 - 0
Firestore/Example/Tests/Integration/CAcert.pem


+ 239 - 0
Firestore/Example/Tests/Integration/FSTDatastoreTests.m

@@ -0,0 +1,239 @@
+/*
+ * 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;
+
+#import <GRPCClient/GRPCCall+ChannelCredentials.h>
+#import <GRPCClient/GRPCCall+Tests.h>
+#import <XCTest/XCTest.h>
+
+#import "API/FIRDocumentReference+Internal.h"
+#import "API/FSTUserDataConverter.h"
+#import "Auth/FSTEmptyCredentialsProvider.h"
+#import "Core/FSTDatabaseInfo.h"
+#import "Core/FSTFirestoreClient.h"
+#import "Core/FSTQuery.h"
+#import "Core/FSTSnapshotVersion.h"
+#import "Core/FSTTimestamp.h"
+#import "Local/FSTQueryData.h"
+#import "Model/FSTDatabaseID.h"
+#import "Model/FSTDocumentKey.h"
+#import "Model/FSTFieldValue.h"
+#import "Model/FSTMutation.h"
+#import "Model/FSTMutationBatch.h"
+#import "Model/FSTPath.h"
+#import "Remote/FSTDatastore.h"
+#import "Remote/FSTRemoteEvent.h"
+#import "Remote/FSTRemoteStore.h"
+#import "Util/FSTAssert.h"
+#import "Util/FSTDispatchQueue.h"
+#import "Util/FSTUtil.h"
+
+#import "FSTIntegrationTestCase.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTRemoteStore (Tests)
+- (void)commitBatch:(FSTMutationBatch *)batch;
+@end
+
+#pragma mark - FSTRemoteStoreEventCapture
+
+@interface FSTRemoteStoreEventCapture : NSObject <FSTRemoteSyncer>
+
+- (instancetype)init __attribute__((unavailable("Use initWithTestCase:")));
+
+- (instancetype)initWithTestCase:(XCTestCase *_Nullable)testCase NS_DESIGNATED_INITIALIZER;
+
+- (void)expectWriteEventWithDescription:(NSString *)description;
+- (void)expectListenEventWithDescription:(NSString *)description;
+
+@property(nonatomic, weak, nullable) XCTestCase *testCase;
+@property(nonatomic, strong) NSMutableArray<NSObject *> *writeEvents;
+@property(nonatomic, strong) NSMutableArray<NSObject *> *listenEvents;
+@property(nonatomic, strong) NSMutableArray<XCTestExpectation *> *writeEventExpectations;
+@property(nonatomic, strong) NSMutableArray<XCTestExpectation *> *listenEventExpectations;
+@end
+
+@implementation FSTRemoteStoreEventCapture
+
+- (instancetype)initWithTestCase:(XCTestCase *_Nullable)testCase {
+  if (self = [super init]) {
+    _writeEvents = [NSMutableArray array];
+    _listenEvents = [NSMutableArray array];
+    _testCase = testCase;
+    _writeEventExpectations = [NSMutableArray array];
+    _listenEventExpectations = [NSMutableArray array];
+  }
+  return self;
+}
+
+- (void)expectWriteEventWithDescription:(NSString *)description {
+  [self.writeEventExpectations
+      addObject:[self.testCase
+                    expectationWithDescription:[NSString
+                                                   stringWithFormat:@"write event %lu: %@",
+                                                                    (unsigned long)
+                                                                        self.writeEventExpectations
+                                                                            .count,
+                                                                    description]]];
+}
+
+- (void)expectListenEventWithDescription:(NSString *)description {
+  [self.listenEventExpectations
+      addObject:[self.testCase
+                    expectationWithDescription:[NSString
+                                                   stringWithFormat:@"listen event %lu: %@",
+                                                                    (unsigned long)
+                                                                        self.listenEventExpectations
+                                                                            .count,
+                                                                    description]]];
+}
+
+- (void)applySuccessfulWriteWithResult:(FSTMutationBatchResult *)batchResult {
+  [self.writeEvents addObject:batchResult];
+  XCTestExpectation *expectation = [self.writeEventExpectations objectAtIndex:0];
+  [self.writeEventExpectations removeObjectAtIndex:0];
+  [expectation fulfill];
+}
+
+- (void)rejectFailedWriteWithBatchID:(FSTBatchID)batchID error:(NSError *)error {
+  FSTFail(@"Not implemented");
+}
+
+- (void)applyRemoteEvent:(FSTRemoteEvent *)remoteEvent {
+  [self.listenEvents addObject:remoteEvent];
+  XCTestExpectation *expectation = [self.listenEventExpectations objectAtIndex:0];
+  [self.listenEventExpectations removeObjectAtIndex:0];
+  [expectation fulfill];
+}
+
+- (void)rejectListenWithTargetID:(FSTBoxedTargetID *)targetID error:(NSError *)error {
+  FSTFail(@"Not implemented");
+}
+
+@end
+
+#pragma mark - FSTDatastoreTests
+
+@interface FSTDatastoreTests : XCTestCase
+
+@end
+
+@implementation FSTDatastoreTests {
+  FSTDispatchQueue *_testWorkerQueue;
+  FSTLocalStore *_localStore;
+  id<FSTCredentialsProvider> _credentials;
+
+  FSTDatastore *_datastore;
+  FSTRemoteStore *_remoteStore;
+}
+
+- (void)setUp {
+  [super setUp];
+
+  NSString *projectID = [[NSProcessInfo processInfo] environment][@"PROJECT_ID"];
+  if (!projectID) {
+    projectID = @"test-db";
+  }
+
+  FIRFirestoreSettings *settings = [FSTIntegrationTestCase settings];
+  if (!settings.sslEnabled) {
+    [GRPCCall useInsecureConnectionsForHost:settings.host];
+  }
+
+  FSTDatabaseID *databaseID =
+      [FSTDatabaseID databaseIDWithProject:projectID database:kDefaultDatabaseID];
+
+  FSTDatabaseInfo *databaseInfo = [FSTDatabaseInfo databaseInfoWithDatabaseID:databaseID
+                                                               persistenceKey:@"test-key"
+                                                                         host:settings.host
+                                                                   sslEnabled:settings.sslEnabled];
+
+  _testWorkerQueue = [FSTDispatchQueue
+      queueWith:dispatch_queue_create("com.google.firestore.FSTDatastoreTestsWorkerQueue",
+                                      DISPATCH_QUEUE_SERIAL)];
+
+  _credentials = [[FSTEmptyCredentialsProvider alloc] init];
+
+  _datastore = [FSTDatastore datastoreWithDatabase:databaseInfo
+                               workerDispatchQueue:_testWorkerQueue
+                                       credentials:_credentials];
+
+  _remoteStore = [FSTRemoteStore remoteStoreWithLocalStore:_localStore datastore:_datastore];
+  [_remoteStore start];
+}
+
+- (void)tearDown {
+  XCTestExpectation *completion = [self expectationWithDescription:@"shutdown"];
+  [_testWorkerQueue dispatchAsync:^{
+    [_remoteStore shutdown];
+    [completion fulfill];
+  }];
+  [self awaitExpectations];
+
+  [super tearDown];
+}
+
+- (void)testCommit {
+  XCTestExpectation *expectation = [self expectationWithDescription:@"commitWithCompletion"];
+
+  [_datastore commitMutations:@[]
+                   completion:^(NSError *_Nullable error) {
+                     XCTAssertNil(error, @"Failed to commit");
+                     [expectation fulfill];
+                   }];
+
+  [self awaitExpectations];
+}
+
+- (void)testStreamingWrite {
+  FSTRemoteStoreEventCapture *capture = [[FSTRemoteStoreEventCapture alloc] initWithTestCase:self];
+  [capture expectWriteEventWithDescription:@"write mutations"];
+
+  _remoteStore.syncEngine = capture;
+
+  FSTSetMutation *mutation = [self setMutation];
+  FSTMutationBatch *batch = [[FSTMutationBatch alloc] initWithBatchID:23
+                                                       localWriteTime:[FSTTimestamp timestamp]
+                                                            mutations:@[ mutation ]];
+  [_testWorkerQueue dispatchAsync:^{
+    [_remoteStore commitBatch:batch];
+  }];
+
+  [self awaitExpectations];
+}
+
+- (void)awaitExpectations {
+  [self waitForExpectationsWithTimeout:4.0
+                               handler:^(NSError *_Nullable expectationError) {
+                                 if (expectationError) {
+                                   XCTFail(@"Error waiting for timeout: %@", expectationError);
+                                 }
+                               }];
+}
+
+- (FSTSetMutation *)setMutation {
+  return [[FSTSetMutation alloc]
+       initWithKey:[FSTDocumentKey keyWithPathString:@"rooms/eros"]
+             value:[[FSTObjectValue alloc]
+                       initWithDictionary:@{@"name" : [FSTStringValue stringValue:@"Eros"]}]
+      precondition:[FSTPrecondition none]];
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 129 - 0
Firestore/Example/Tests/Integration/FSTSmokeTests.m

@@ -0,0 +1,129 @@
+/*
+ * 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;
+
+#import <XCTest/XCTest.h>
+
+#import "FSTEventAccumulator.h"
+#import "FSTIntegrationTestCase.h"
+
+@interface FSTSmokeTests : FSTIntegrationTestCase
+@end
+
+@implementation FSTSmokeTests
+
+- (void)testCanWriteASingleDocument {
+  FIRDocumentReference *ref = [self documentRef];
+  [self writeDocumentRef:ref data:[self chatMessage]];
+}
+
+- (void)testCanReadAWrittenDocument {
+  NSDictionary<NSString *, id> *data = [self chatMessage];
+
+  FIRDocumentReference *ref = [self documentRef];
+  [self writeDocumentRef:ref data:data];
+
+  FIRDocumentSnapshot *doc = [self readDocumentForRef:ref];
+  XCTAssertEqualObjects(doc.data, data);
+}
+
+- (void)testObservesExistingDocument {
+  [self readerAndWriterOnDocumentRef:^(NSString *path, FIRDocumentReference *readerRef,
+                                       FIRDocumentReference *writerRef) {
+    NSDictionary<NSString *, id> *data = [self chatMessage];
+    [self writeDocumentRef:writerRef data:data];
+
+    id<FIRListenerRegistration> listenerRegistration =
+        [readerRef addSnapshotListener:self.eventAccumulator.handler];
+
+    FIRDocumentSnapshot *doc = [self.eventAccumulator awaitEventWithName:@"snapshot"];
+    XCTAssertEqual([doc class], [FIRDocumentSnapshot class]);
+    XCTAssertEqualObjects(doc.data, data);
+
+    [listenerRegistration remove];
+  }];
+}
+
+- (void)testObservesNewDocument {
+  [self readerAndWriterOnDocumentRef:^(NSString *path, FIRDocumentReference *readerRef,
+                                       FIRDocumentReference *writerRef) {
+    id<FIRListenerRegistration> listenerRegistration =
+        [readerRef addSnapshotListener:self.eventAccumulator.handler];
+
+    FIRDocumentSnapshot *doc1 = [self.eventAccumulator awaitEventWithName:@"null snapshot"];
+    XCTAssertFalse(doc1.exists);
+    // TODO(b/36366944): add tests for doc1.path)
+
+    NSDictionary<NSString *, id> *data = [self chatMessage];
+    [self writeDocumentRef:writerRef data:data];
+
+    FIRDocumentSnapshot *doc2 = [self.eventAccumulator awaitEventWithName:@"full snapshot"];
+    XCTAssertEqual([doc2 class], [FIRDocumentSnapshot class]);
+    XCTAssertEqualObjects(doc2.data, data);
+
+    [listenerRegistration remove];
+  }];
+}
+
+- (void)testWillFireValueEventsForEmptyCollections {
+  FIRCollectionReference *collection = [self.db collectionWithPath:@"empty-collection"];
+  id<FIRListenerRegistration> listenerRegistration =
+      [collection addSnapshotListener:self.eventAccumulator.handler];
+
+  FIRQuerySnapshot *snap = [self.eventAccumulator awaitEventWithName:@"empty query snapshot"];
+  XCTAssertEqual([snap class], [FIRQuerySnapshot class]);
+  XCTAssertEqual(snap.count, 0);
+
+  [listenerRegistration remove];
+}
+
+- (void)testGetCollectionQuery {
+  NSDictionary<NSString *, id> *testDocs = @{
+    @"1" : @{@"name" : @"Patryk", @"message" : @"Real data, yo!"},
+    @"2" : @{@"name" : @"Gil", @"message" : @"Yep!"},
+    @"3" : @{@"name" : @"Jonny", @"message" : @"Back to work!"},
+  };
+
+  FIRCollectionReference *docs = [self collectionRefWithDocuments:testDocs];
+  FIRQuerySnapshot *result = [self readDocumentSetForRef:docs];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(result),
+                        (@[ testDocs[@"1"], testDocs[@"2"], testDocs[@"3"] ]));
+}
+
+// TODO(klimt): This test is disabled because we can't create compound indexes programmatically.
+- (void)xtestQueryByFieldAndUseOrderBy {
+  NSDictionary<NSString *, id> *testDocs = @{
+    @"1" : @{@"sort" : @1, @"filter" : @YES, @"key" : @"1"},
+    @"2" : @{@"sort" : @2, @"filter" : @YES, @"key" : @"2"},
+    @"3" : @{@"sort" : @2, @"filter" : @YES, @"key" : @"3"},
+    @"4" : @{@"sort" : @3, @"filter" : @NO, @"key" : @"4"}
+  };
+
+  FIRCollectionReference *coll = [self collectionRefWithDocuments:testDocs];
+
+  FIRQuery *query =
+      [[coll queryWhereField:@"filter" isEqualTo:@YES] queryOrderedByField:@"sort" descending:YES];
+  FIRQuerySnapshot *result = [self readDocumentSetForRef:query];
+  XCTAssertEqualObjects(FIRQuerySnapshotGetData(result),
+                        (@[ testDocs[@"2"], testDocs[@"3"], testDocs[@"1"] ]));
+}
+
+- (NSDictionary<NSString *, id> *)chatMessage {
+  return @{@"name" : @"Patryk", @"message" : @"We are actually writing data!"};
+}
+
+@end

+ 541 - 0
Firestore/Example/Tests/Integration/FSTTransactionTests.m

@@ -0,0 +1,541 @@
+/*
+ * 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;
+
+#import <XCTest/XCTest.h>
+#include <libkern/OSAtomic.h>
+
+#import "FSTIntegrationTestCase.h"
+
+@interface FSTTransactionTests : FSTIntegrationTestCase
+@end
+
+@implementation FSTTransactionTests
+
+// We currently require every document read to also be written.
+// TODO(b/34879758): Re-enable this test once we fix it.
+- (void)xtestGetDocuments {
+  FIRFirestore *firestore = [self firestore];
+  FIRDocumentReference *doc = [[firestore collectionWithPath:@"spaces"] documentWithAutoID];
+  [self writeDocumentRef:doc data:@{ @"foo" : @1, @"desc" : @"Stuff", @"owner" : @"Jonny" }];
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"transaction"];
+  [firestore runTransactionWithBlock:^id _Nullable(FIRTransaction *transaction, NSError **error) {
+    [transaction getDocument:doc error:error];
+    XCTAssertNil(*error);
+    return @YES;
+  }
+      completion:^(id _Nullable result, NSError *_Nullable error) {
+        XCTAssertNil(result);
+        // We currently require every document read to also be written.
+        // TODO(b/34879758): Fix this check once we drop that requirement.
+        XCTAssertNotNil(error);
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+}
+
+- (void)testDeleteDocument {
+  FIRFirestore *firestore = [self firestore];
+  FIRDocumentReference *doc = [[firestore collectionWithPath:@"towns"] documentWithAutoID];
+  [self writeDocumentRef:doc data:@{@"foo" : @"bar"}];
+  FIRDocumentSnapshot *snapshot = [self readDocumentForRef:doc];
+  XCTAssertEqualObjects(@"bar", snapshot[@"foo"]);
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"transaction"];
+  [firestore runTransactionWithBlock:^id _Nullable(FIRTransaction *transaction, NSError **error) {
+    [transaction deleteDocument:doc];
+    return @YES;
+  }
+      completion:^(id _Nullable result, NSError *_Nullable error) {
+        XCTAssertEqualObjects(@YES, result);
+        XCTAssertNil(error);
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+
+  snapshot = [self readDocumentForRef:doc];
+  XCTAssertFalse(snapshot.exists);
+}
+
+- (void)testGetNonexistentDocumentThenCreate {
+  FIRFirestore *firestore = [self firestore];
+  FIRDocumentReference *doc = [[firestore collectionWithPath:@"towns"] documentWithAutoID];
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"transaction"];
+  [firestore runTransactionWithBlock:^id _Nullable(FIRTransaction *transaction, NSError **error) {
+    FIRDocumentSnapshot *snapshot = [transaction getDocument:doc error:error];
+    XCTAssertNil(*error);
+    XCTAssertFalse(snapshot.exists);
+    [transaction setData:@{@"foo" : @"bar"} forDocument:doc];
+    return @YES;
+  }
+      completion:^(id _Nullable result, NSError *_Nullable error) {
+        XCTAssertEqualObjects(@YES, result);
+        XCTAssertNil(error);
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+
+  FIRDocumentSnapshot *snapshot = [self readDocumentForRef:doc];
+  XCTAssertTrue(snapshot.exists);
+  XCTAssertEqualObjects(@"bar", snapshot[@"foo"]);
+}
+
+- (void)testGetNonexistentDocumentThenFailPatch {
+  FIRFirestore *firestore = [self firestore];
+  FIRDocumentReference *doc = [[firestore collectionWithPath:@"towns"] documentWithAutoID];
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"transaction"];
+  [firestore runTransactionWithBlock:^id _Nullable(FIRTransaction *transaction, NSError **error) {
+    FIRDocumentSnapshot *snapshot = [transaction getDocument:doc error:error];
+    XCTAssertNil(*error);
+    XCTAssertFalse(snapshot.exists);
+    [transaction updateData:@{@"foo" : @"bar"} forDocument:doc];
+    return @YES;
+  }
+      completion:^(id _Nullable result, NSError *_Nullable error) {
+        XCTAssertNil(result);
+        XCTAssertNotNil(error);
+        XCTAssertEqualObjects(error.domain, FIRFirestoreErrorDomain);
+        // TODO(dimond): This is probably the wrong error code, but it's what we use today. We
+        // should update the code once the underlying error was fixed.
+        XCTAssertEqual(error.code, FIRFirestoreErrorCodeFailedPrecondition);
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+}
+
+- (void)testDeleteDocumentAndPatch {
+  FIRFirestore *firestore = [self firestore];
+  FIRDocumentReference *doc = [[firestore collectionWithPath:@"towns"] documentWithAutoID];
+  [self writeDocumentRef:doc data:@{@"foo" : @"bar"}];
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"transaction"];
+  [firestore runTransactionWithBlock:^id(FIRTransaction *transaction, NSError **error) {
+    FIRDocumentSnapshot *snapshot = [transaction getDocument:doc error:error];
+    XCTAssertNil(*error);
+    XCTAssertTrue(snapshot.exists);
+    [transaction deleteDocument:doc];
+    // Since we deleted the doc, the update will fail
+    [transaction updateData:@{@"foo" : @"bar"} forDocument:doc];
+    return @YES;
+  }
+      completion:^(id _Nullable result, NSError *_Nullable error) {
+        XCTAssertNil(result);
+        XCTAssertNotNil(error);
+        XCTAssertEqualObjects(error.domain, FIRFirestoreErrorDomain);
+        // TODO(dimond): This is probably the wrong error code, but it's what we use today. We
+        // should update the code once the underlying error was fixed.
+        XCTAssertEqual(error.code, FIRFirestoreErrorCodeFailedPrecondition);
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+}
+
+- (void)testDeleteDocumentAndSet {
+  FIRFirestore *firestore = [self firestore];
+  FIRDocumentReference *doc = [[firestore collectionWithPath:@"towns"] documentWithAutoID];
+  [self writeDocumentRef:doc data:@{@"foo" : @"bar"}];
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"transaction"];
+  [firestore runTransactionWithBlock:^id(FIRTransaction *transaction, NSError **error) {
+    FIRDocumentSnapshot *snapshot = [transaction getDocument:doc error:error];
+    XCTAssertNil(*error);
+    XCTAssertTrue(snapshot.exists);
+    [transaction deleteDocument:doc];
+    // TODO(dimond): In theory this should work, but it's complex to make it work, so instead we
+    // just let the transaction fail and verify it's unsupported for now
+    [transaction setData:@{@"foo" : @"new-bar"} forDocument:doc];
+    return @YES;
+  }
+      completion:^(id _Nullable result, NSError *_Nullable error) {
+        XCTAssertNil(result);
+        XCTAssertNotNil(error);
+        XCTAssertEqualObjects(error.domain, FIRFirestoreErrorDomain);
+        // TODO(dimond): This is probably the wrong error code, but it's what we use today. We
+        // should update the code once the underlying error was fixed.
+        XCTAssertEqual(error.code, FIRFirestoreErrorCodeFailedPrecondition);
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+}
+
+- (void)testWriteDocumentTwice {
+  FIRFirestore *firestore = [self firestore];
+  FIRDocumentReference *doc = [[firestore collectionWithPath:@"towns"] documentWithAutoID];
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"transaction"];
+  [firestore runTransactionWithBlock:^id(FIRTransaction *transaction, NSError **error) {
+    [transaction setData:@{@"a" : @"b"} forDocument:doc];
+    [transaction setData:@{@"c" : @"d"} forDocument:doc];
+    return @YES;
+  }
+      completion:^(id _Nullable result, NSError *_Nullable error) {
+        XCTAssertEqualObjects(@YES, result);
+        XCTAssertNil(error);
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+
+  FIRDocumentSnapshot *snapshot = [self readDocumentForRef:doc];
+  XCTAssertEqualObjects(snapshot.data, @{@"c" : @"d"});
+}
+
+- (void)testSetDocumentWithMerge {
+  FIRFirestore *firestore = [self firestore];
+  FIRDocumentReference *doc = [[firestore collectionWithPath:@"towns"] documentWithAutoID];
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"transaction"];
+  [firestore runTransactionWithBlock:^id _Nullable(FIRTransaction *transaction, NSError **error) {
+    [transaction setData:@{ @"a" : @"b", @"nested" : @{@"a" : @"b"} } forDocument:doc];
+    [transaction setData:@{
+      @"c" : @"d",
+      @"nested" : @{@"c" : @"d"}
+    }
+             forDocument:doc
+                 options:[FIRSetOptions merge]];
+    return @YES;
+  }
+      completion:^(id _Nullable result, NSError *_Nullable error) {
+        XCTAssertEqualObjects(@YES, result);
+        XCTAssertNil(error);
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+
+  FIRDocumentSnapshot *snapshot = [self readDocumentForRef:doc];
+  XCTAssertEqualObjects(
+      snapshot.data, (
+                         @{ @"a" : @"b",
+                            @"c" : @"d",
+                            @"nested" : @{@"a" : @"b", @"c" : @"d"} }));
+}
+
+- (void)testCannotUpdateNonExistentDocument {
+  FIRFirestore *firestore = [self firestore];
+  FIRDocumentReference *doc = [[firestore collectionWithPath:@"towns"] documentWithAutoID];
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"transaction"];
+  [firestore runTransactionWithBlock:^id _Nullable(FIRTransaction *transaction, NSError **error) {
+    [transaction updateData:@{@"foo" : @"bar"} forDocument:doc];
+    return nil;
+  }
+      completion:^(id _Nullable result, NSError *_Nullable error) {
+        XCTAssertNotNil(error);
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+
+  FIRDocumentSnapshot *result = [self readDocumentForRef:doc];
+  XCTAssertFalse(result.exists);
+}
+
+- (void)testIncrementTransactionally {
+  // A barrier to make sure every transaction reaches the same spot.
+  dispatch_semaphore_t writeBarrier = dispatch_semaphore_create(0);
+  __block volatile int32_t started = 0;
+
+  FIRFirestore *firestore = [self firestore];
+  FIRDocumentReference *doc = [[firestore collectionWithPath:@"counters"] documentWithAutoID];
+  [self writeDocumentRef:doc data:@{ @"count" : @(5.0) }];
+
+  // Make 3 transactions that will all increment.
+  int total = 3;
+  for (int i = 0; i < total; i++) {
+    XCTestExpectation *expectation = [self expectationWithDescription:@"transaction"];
+    [firestore runTransactionWithBlock:^id _Nullable(FIRTransaction *transaction, NSError **error) {
+      FIRDocumentSnapshot *snapshot = [transaction getDocument:doc error:error];
+      XCTAssertNil(*error);
+      int32_t nowStarted = OSAtomicIncrement32(&started);
+      // Once all of the transactions have read, allow the first write.
+      if (nowStarted == total) {
+        dispatch_semaphore_signal(writeBarrier);
+      }
+
+      dispatch_semaphore_wait(writeBarrier, DISPATCH_TIME_FOREVER);
+      // Refill the barrier so that the other transactions and retries succeed.
+      dispatch_semaphore_signal(writeBarrier);
+
+      double newCount = ((NSNumber *)snapshot[@"count"]).doubleValue + 1.0;
+      [transaction setData:@{ @"count" : @(newCount) } forDocument:doc];
+      return @YES;
+
+    }
+        completion:^(id _Nullable result, NSError *_Nullable error) {
+          [expectation fulfill];
+        }];
+  }
+
+  [self awaitExpectations];
+  // Now all transaction should be completed, so check the result.
+  FIRDocumentSnapshot *snapshot = [self readDocumentForRef:doc];
+  XCTAssertEqualObjects(@(5.0 + total), snapshot[@"count"]);
+}
+
+- (void)testUpdateTransactionally {
+  // A barrier to make sure every transaction reaches the same spot.
+  dispatch_semaphore_t writeBarrier = dispatch_semaphore_create(0);
+  __block volatile int32_t started = 0;
+
+  FIRFirestore *firestore = [self firestore];
+  FIRDocumentReference *doc = [[firestore collectionWithPath:@"counters"] documentWithAutoID];
+  [self writeDocumentRef:doc data:@{ @"count" : @(5.0), @"other" : @"yes" }];
+
+  // Make 3 transactions that will all increment.
+  int total = 3;
+  for (int i = 0; i < total; i++) {
+    XCTestExpectation *expectation = [self expectationWithDescription:@"transaction"];
+    [firestore runTransactionWithBlock:^id _Nullable(FIRTransaction *transaction, NSError **error) {
+      FIRDocumentSnapshot *snapshot = [transaction getDocument:doc error:error];
+      XCTAssertNil(*error);
+      int32_t nowStarted = OSAtomicIncrement32(&started);
+      // Once all of the transactions have read, allow the first write.
+      if (nowStarted == total) {
+        dispatch_semaphore_signal(writeBarrier);
+      }
+
+      dispatch_semaphore_wait(writeBarrier, DISPATCH_TIME_FOREVER);
+      // Refill the barrier so that the other transactions and retries succeed.
+      dispatch_semaphore_signal(writeBarrier);
+
+      double newCount = ((NSNumber *)snapshot[@"count"]).doubleValue + 1.0;
+      [transaction updateData:@{ @"count" : @(newCount) } forDocument:doc];
+      return @YES;
+
+    }
+        completion:^(id _Nullable result, NSError *_Nullable error) {
+          [expectation fulfill];
+        }];
+  }
+
+  [self awaitExpectations];
+  // Now all transaction should be completed, so check the result.
+  FIRDocumentSnapshot *snapshot = [self readDocumentForRef:doc];
+  XCTAssertEqualObjects(@(5.0 + total), snapshot[@"count"]);
+  XCTAssertEqualObjects(@"yes", snapshot[@"other"]);
+}
+
+// We currently require every document read to also be written.
+// TODO(b/34879758): Re-enable this test once we fix it.
+- (void)xtestHandleReadingOneDocAndWritingAnother {
+  FIRFirestore *firestore = [self firestore];
+  FIRDocumentReference *doc1 = [[firestore collectionWithPath:@"counters"] documentWithAutoID];
+  FIRDocumentReference *doc2 = [[firestore collectionWithPath:@"counters"] documentWithAutoID];
+
+  [self writeDocumentRef:doc1 data:@{ @"count" : @(15.0) }];
+
+  XCTestExpectation *expectation = [self expectationWithDescription:@"transaction"];
+  [firestore runTransactionWithBlock:^id _Nullable(FIRTransaction *transaction, NSError **error) {
+    // Get the first doc.
+    [transaction getDocument:doc1 error:error];
+    XCTAssertNil(*error);
+    // Do a write outside of the transaction. The first time the
+    // transaction is tried, this will bump the version, which
+    // will cause the write to doc2 to fail. The second time, it
+    // will be a no-op and not bump the version.
+    dispatch_semaphore_t writeSemaphore = dispatch_semaphore_create(0);
+    [doc1 setData:@{
+      @"count" : @(1234)
+    }
+        completion:^(NSError *_Nullable error) {
+          dispatch_semaphore_signal(writeSemaphore);
+        }];
+    // We can block on it, because transactions run on a background queue.
+    dispatch_semaphore_wait(writeSemaphore, DISPATCH_TIME_FOREVER);
+    // Now try to update the other doc from within the transaction.
+    // This should fail once, because we read 15 earlier.
+    [transaction setData:@{ @"count" : @(16) } forDocument:doc2];
+    return nil;
+  }
+      completion:^(id _Nullable result, NSError *_Nullable error) {
+        // We currently require every document read to also be written.
+        // TODO(b/34879758): Add this check back once we drop that.
+        // NSError *error = nil;
+        // FIRDocument *snapshot = [transaction getDocument:doc1 error:&error];
+        // XCTAssertNil(error);
+        // XCTAssertEquals(0, tries);
+        // XCTAssertEqualObjects(@(1234), snapshot[@"count"]);
+        // snapshot = [transaction getDocument:doc2 error:&error];
+        // XCTAssertNil(error);
+        // XCTAssertEqualObjects(@(16), snapshot[@"count"]);
+        XCTAssertNotNil(error);
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+}
+
+- (void)testReadingADocTwiceWithDifferentVersions {
+  FIRFirestore *firestore = [self firestore];
+  FIRDocumentReference *doc = [[firestore collectionWithPath:@"counters"] documentWithAutoID];
+  [self writeDocumentRef:doc data:@{ @"count" : @(15.0) }];
+  XCTestExpectation *expectation = [self expectationWithDescription:@"transaction"];
+  [firestore runTransactionWithBlock:^id _Nullable(FIRTransaction *transaction, NSError **error) {
+    // Get the doc once.
+    FIRDocumentSnapshot *snapshot = [transaction getDocument:doc error:error];
+    XCTAssertNil(*error);
+    XCTAssertEqualObjects(@(15), snapshot[@"count"]);
+    // Do a write outside of the transaction.
+    dispatch_semaphore_t writeSemaphore = dispatch_semaphore_create(0);
+    [doc setData:@{
+      @"count" : @(1234)
+    }
+        completion:^(NSError *_Nullable error) {
+          dispatch_semaphore_signal(writeSemaphore);
+        }];
+    // We can block on it, because transactions run on a background queue.
+    dispatch_semaphore_wait(writeSemaphore, DISPATCH_TIME_FOREVER);
+    // Get the doc again in the transaction with the new version.
+    snapshot = [transaction getDocument:doc error:error];
+    // The get itself will fail, because we already read an earlier version of this document.
+    // TODO(klimt): Perhaps we shouldn't fail reads for this, but should wait and fail the
+    // whole transaction? It's an edge-case anyway, as developers shouldn't be reading the same
+    // do multiple times. But they need to handle read errors anyway.
+    XCTAssertNotNil(*error);
+    return nil;
+  }
+      completion:^(id _Nullable result, NSError *_Nullable error) {
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+
+  FIRDocumentSnapshot *snapshot = [self readDocumentForRef:doc];
+  XCTAssertEqualObjects(@(1234.0), snapshot[@"count"]);
+}
+
+// We currently require every document read to also be written.
+// TODO(b/34879758): Add this test back once we fix that.
+- (void)xtestCannotHaveAGetWithoutMutations {
+  FIRFirestore *firestore = [self firestore];
+  FIRDocumentReference *doc = [[firestore collectionWithPath:@"foo"] documentWithAutoID];
+  [self writeDocumentRef:doc data:@{@"foo" : @"bar"}];
+  XCTestExpectation *expectation = [self expectationWithDescription:@"transaction"];
+  [firestore runTransactionWithBlock:^id _Nullable(FIRTransaction *transaction, NSError **error) {
+    FIRDocumentSnapshot *snapshot = [transaction getDocument:doc error:error];
+    XCTAssertTrue(snapshot.exists);
+    XCTAssertNil(*error);
+    return nil;
+  }
+      completion:^(id _Nullable result, NSError *_Nullable error) {
+        XCTAssertNotNil(error);
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+}
+
+- (void)testSuccessWithNoTransactionOperations {
+  FIRFirestore *firestore = [self firestore];
+  XCTestExpectation *expectation = [self expectationWithDescription:@"transaction"];
+  [firestore runTransactionWithBlock:^id _Nullable(FIRTransaction *transaction, NSError **error) {
+    return @"yes";
+  }
+      completion:^(id _Nullable result, NSError *_Nullable error) {
+        XCTAssertEqualObjects(@"yes", result);
+        XCTAssertNil(error);
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+}
+
+- (void)testCancellationOnError {
+  FIRFirestore *firestore = [self firestore];
+  FIRDocumentReference *doc = [[firestore collectionWithPath:@"towns"] documentWithAutoID];
+  __block volatile int32_t count = 0;
+  XCTestExpectation *expectation = [self expectationWithDescription:@"transaction"];
+  [firestore runTransactionWithBlock:^id _Nullable(FIRTransaction *transaction, NSError **error) {
+    OSAtomicIncrement32(&count);
+    [transaction setData:@{@"foo" : @"bar"} forDocument:doc];
+    *error = [NSError errorWithDomain:NSCocoaErrorDomain code:35 userInfo:@{}];
+    return nil;
+  }
+      completion:^(id _Nullable result, NSError *_Nullable error) {
+        XCTAssertNil(result);
+        XCTAssertNotNil(error);
+        XCTAssertEqual(35, error.code);
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+  XCTAssertEqual(1, (int)count);
+  FIRDocumentSnapshot *snapshot = [self readDocumentForRef:doc];
+  XCTAssertFalse(snapshot.exists);
+}
+
+- (void)testUpdateFieldsWithDotsTransactionally {
+  FIRDocumentReference *doc = [self documentRef];
+
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"testUpdateFieldsWithDotsTransactionally"];
+
+  [doc.firestore
+      runTransactionWithBlock:^id _Nullable(FIRTransaction *transaction, NSError **error) {
+        XCTAssertNil(*error);
+        [transaction setData:@{@"a.b" : @"old", @"c.d" : @"old"} forDocument:doc];
+        [transaction updateData:@{
+          [[FIRFieldPath alloc] initWithFields:@[ @"a.b" ]] : @"new"
+        }
+                    forDocument:doc];
+        return nil;
+      }
+      completion:^(id result, NSError *error) {
+        XCTAssertNil(error);
+        [doc getDocumentWithCompletion:^(FIRDocumentSnapshot *snapshot, NSError *error) {
+          XCTAssertNil(error);
+          XCTAssertEqualObjects(snapshot.data, (@{@"a.b" : @"new", @"c.d" : @"old"}));
+        }];
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+}
+
+- (void)testUpdateNestedFieldsTransactionally {
+  FIRDocumentReference *doc = [self documentRef];
+
+  XCTestExpectation *expectation =
+      [self expectationWithDescription:@"testUpdateNestedFieldsTransactionally"];
+
+  [doc.firestore
+      runTransactionWithBlock:^id _Nullable(FIRTransaction *transaction, NSError **error) {
+        XCTAssertNil(*error);
+        [transaction setData:@{
+          @"a" : @{@"b" : @"old"},
+          @"c" : @{@"d" : @"old"},
+          @"e" : @{@"f" : @"old"}
+        }
+                 forDocument:doc];
+        [transaction updateData:@{
+          @"a.b" : @"new",
+          [[FIRFieldPath alloc] initWithFields:@[ @"c", @"d" ]] : @"new"
+        }
+                    forDocument:doc];
+        return nil;
+      }
+      completion:^(id result, NSError *error) {
+        XCTAssertNil(error);
+        [doc getDocumentWithCompletion:^(FIRDocumentSnapshot *snapshot, NSError *error) {
+          XCTAssertNil(error);
+          XCTAssertEqualObjects(snapshot.data, (@{
+                                  @"a" : @{@"b" : @"new"},
+                                  @"c" : @{@"d" : @"new"},
+                                  @"e" : @{@"f" : @"old"}
+                                }));
+        }];
+        [expectation fulfill];
+      }];
+  [self awaitExpectations];
+}
+
+@end

+ 111 - 0
Firestore/Example/Tests/Local/FSTEagerGarbageCollectorTests.m

@@ -0,0 +1,111 @@
+/*
+ * 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 "Local/FSTEagerGarbageCollector.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Local/FSTReferenceSet.h"
+#import "Model/FSTDocumentKey.h"
+
+#import "FSTHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTEagerGarbageCollectorTests : XCTestCase
+@end
+
+@implementation FSTEagerGarbageCollectorTests
+
+- (void)testAddOrRemoveReferences {
+  FSTEagerGarbageCollector *gc = [[FSTEagerGarbageCollector alloc] init];
+  FSTReferenceSet *referenceSet = [[FSTReferenceSet alloc] init];
+  [gc addGarbageSource:referenceSet];
+
+  FSTDocumentKey *key = [FSTDocumentKey keyWithPathString:@"foo/bar"];
+  [referenceSet addReferenceToKey:key forID:1];
+  FSTAssertEqualSets([gc collectGarbage], @[]);
+  XCTAssertFalse([referenceSet isEmpty]);
+
+  [referenceSet removeReferenceToKey:key forID:1];
+  FSTAssertEqualSets([gc collectGarbage], @[ key ]);
+  XCTAssertTrue([referenceSet isEmpty]);
+}
+
+- (void)testRemoveAllReferencesForID {
+  FSTEagerGarbageCollector *gc = [[FSTEagerGarbageCollector alloc] init];
+  FSTReferenceSet *referenceSet = [[FSTReferenceSet alloc] init];
+  [gc addGarbageSource:referenceSet];
+
+  FSTDocumentKey *key1 = [FSTDocumentKey keyWithPathString:@"foo/bar"];
+  FSTDocumentKey *key2 = [FSTDocumentKey keyWithPathString:@"foo/baz"];
+  FSTDocumentKey *key3 = [FSTDocumentKey keyWithPathString:@"foo/blah"];
+  [referenceSet addReferenceToKey:key1 forID:1];
+  [referenceSet addReferenceToKey:key2 forID:1];
+  [referenceSet addReferenceToKey:key3 forID:2];
+  XCTAssertFalse([referenceSet isEmpty]);
+
+  [referenceSet removeReferencesForID:1];
+  FSTAssertEqualSets([gc collectGarbage], (@[ key1, key2 ]));
+  XCTAssertFalse([referenceSet isEmpty]);
+
+  [referenceSet removeReferencesForID:2];
+  FSTAssertEqualSets([gc collectGarbage], @[ key3 ]);
+  XCTAssertTrue([referenceSet isEmpty]);
+}
+
+- (void)testTwoReferenceSetsAtTheSameTime {
+  FSTReferenceSet *remoteTargets = [[FSTReferenceSet alloc] init];
+  FSTReferenceSet *localViews = [[FSTReferenceSet alloc] init];
+  FSTReferenceSet *mutations = [[FSTReferenceSet alloc] init];
+
+  FSTEagerGarbageCollector *gc = [[FSTEagerGarbageCollector alloc] init];
+  [gc addGarbageSource:remoteTargets];
+  [gc addGarbageSource:localViews];
+  [gc addGarbageSource:mutations];
+
+  FSTDocumentKey *key1 = [FSTDocumentKey keyWithPathString:@"foo/bar"];
+  [remoteTargets addReferenceToKey:key1 forID:1];
+  [localViews addReferenceToKey:key1 forID:1];
+  [mutations addReferenceToKey:key1 forID:10];
+
+  FSTDocumentKey *key2 = [FSTDocumentKey keyWithPathString:@"foo/baz"];
+  [mutations addReferenceToKey:key2 forID:10];
+
+  XCTAssertFalse([remoteTargets isEmpty]);
+  XCTAssertFalse([localViews isEmpty]);
+  XCTAssertFalse([mutations isEmpty]);
+
+  [localViews removeReferencesForID:1];
+  FSTAssertEqualSets([gc collectGarbage], @[]);
+
+  [remoteTargets removeReferencesForID:1];
+  FSTAssertEqualSets([gc collectGarbage], @[]);
+
+  [mutations removeReferenceToKey:key1 forID:10];
+  FSTAssertEqualSets([gc collectGarbage], @[ key1 ]);
+
+  [mutations removeReferenceToKey:key2 forID:10];
+  FSTAssertEqualSets([gc collectGarbage], @[ key2 ]);
+
+  XCTAssertTrue([remoteTargets isEmpty]);
+  XCTAssertTrue([localViews isEmpty]);
+  XCTAssertTrue([mutations isEmpty]);
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 361 - 0
Firestore/Example/Tests/Local/FSTLevelDBKeyTests.mm

@@ -0,0 +1,361 @@
+/*
+ * 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 "Local/FSTLevelDBKey.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Model/FSTPath.h"
+
+#import "FSTHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTLevelDBKeyTests : XCTestCase
+@end
+
+// I can't believe I have to write this...
+bool StartsWith(const std::string &value, const std::string &prefix) {
+  return prefix.size() <= value.size() && std::equal(prefix.begin(), prefix.end(), value.begin());
+}
+
+static std::string RemoteDocKey(NSString *pathString) {
+  return [FSTLevelDBRemoteDocumentKey keyWithDocumentKey:FSTTestDocKey(pathString)];
+}
+
+static std::string RemoteDocKeyPrefix(NSString *pathString) {
+  return [FSTLevelDBRemoteDocumentKey keyPrefixWithResourcePath:FSTTestPath(pathString)];
+}
+
+static std::string DocMutationKey(NSString *userID, NSString *key, FSTBatchID batchID) {
+  return [FSTLevelDBDocumentMutationKey keyWithUserID:userID
+                                          documentKey:FSTTestDocKey(key)
+                                              batchID:batchID];
+}
+
+static std::string TargetDocKey(FSTTargetID targetID, NSString *key) {
+  return [FSTLevelDBTargetDocumentKey keyWithTargetID:targetID documentKey:FSTTestDocKey(key)];
+}
+
+static std::string DocTargetKey(NSString *key, FSTTargetID targetID) {
+  return [FSTLevelDBDocumentTargetKey keyWithDocumentKey:FSTTestDocKey(key) targetID:targetID];
+}
+
+/**
+ * Asserts that the description for given key is equal to the expected description.
+ *
+ * @param key A StringView of a textual key
+ * @param key An NSString that [FSTLevelDBKey descriptionForKey:] is expected to produce.
+ */
+#define FSTAssertExpectedKeyDescription(key, expectedDescription) \
+  XCTAssertEqualObjects([FSTLevelDBKey descriptionForKey:(key)], (expectedDescription))
+
+#define FSTAssertKeyLessThan(left, right)                                           \
+  do {                                                                              \
+    std::string leftKey = (left);                                                   \
+    std::string rightKey = (right);                                                 \
+    XCTAssertLessThan(leftKey.compare(right), 0, @"Expected %@ to be less than %@", \
+                      [FSTLevelDBKey descriptionForKey:leftKey],                    \
+                      [FSTLevelDBKey descriptionForKey:rightKey]);                  \
+  } while (0)
+
+@implementation FSTLevelDBKeyTests
+
+- (void)testMutationKeyPrefixing {
+  auto tableKey = [FSTLevelDBMutationKey keyPrefix];
+  auto emptyUserKey = [FSTLevelDBMutationKey keyPrefixWithUserID:""];
+  auto fooUserKey = [FSTLevelDBMutationKey keyPrefixWithUserID:"foo"];
+
+  auto foo2Key = [FSTLevelDBMutationKey keyWithUserID:"foo" batchID:2];
+
+  XCTAssertTrue(StartsWith(emptyUserKey, tableKey));
+
+  // This is critical: prefixes of the a value don't convert into prefixes of the key.
+  XCTAssertTrue(StartsWith(fooUserKey, tableKey));
+  XCTAssertFalse(StartsWith(fooUserKey, emptyUserKey));
+
+  // However whole segments in common are prefixes.
+  XCTAssertTrue(StartsWith(foo2Key, tableKey));
+  XCTAssertTrue(StartsWith(foo2Key, fooUserKey));
+}
+
+- (void)testMutationKeyEncodeDecodeCycle {
+  FSTLevelDBMutationKey *key = [[FSTLevelDBMutationKey alloc] init];
+  std::string user("foo");
+
+  NSArray<NSNumber *> *batchIds = @[ @0, @1, @100, @(INT_MAX - 1), @(INT_MAX) ];
+  for (NSNumber *batchIDNumber in batchIds) {
+    FSTBatchID batchID = [batchIDNumber intValue];
+    auto encoded = [FSTLevelDBMutationKey keyWithUserID:user batchID:batchID];
+
+    BOOL ok = [key decodeKey:encoded];
+    XCTAssertTrue(ok);
+    XCTAssertEqual(key.userID, user);
+    XCTAssertEqual(key.batchID, batchID);
+  }
+}
+
+- (void)testMutationKeyDescription {
+  FSTAssertExpectedKeyDescription([FSTLevelDBMutationKey keyPrefix], @"[mutation: incomplete key]");
+
+  FSTAssertExpectedKeyDescription([FSTLevelDBMutationKey keyPrefixWithUserID:@"user1"],
+                                  @"[mutation: userID=user1 incomplete key]");
+
+  auto key = [FSTLevelDBMutationKey keyWithUserID:@"user1" batchID:42];
+  FSTAssertExpectedKeyDescription(key, @"[mutation: userID=user1 batchID=42]");
+
+  FSTAssertExpectedKeyDescription(key + " extra",
+                                  @"[mutation: userID=user1 batchID=42 invalid "
+                                  @"key=<hW11dGF0aW9uAAGNdXNlcjEAAYqqgCBleHRyYQ==>]");
+
+  // Truncate the key so that it's missing its terminator.
+  key.resize(key.size() - 1);
+  FSTAssertExpectedKeyDescription(key, @"[mutation: userID=user1 batchID=42 incomplete key]");
+}
+
+- (void)testDocumentMutationKeyPrefixing {
+  auto tableKey = [FSTLevelDBDocumentMutationKey keyPrefix];
+  auto emptyUserKey = [FSTLevelDBDocumentMutationKey keyPrefixWithUserID:""];
+  auto fooUserKey = [FSTLevelDBDocumentMutationKey keyPrefixWithUserID:"foo"];
+
+  FSTDocumentKey *documentKey = FSTTestDocKey(@"foo/bar");
+  auto foo2Key =
+      [FSTLevelDBDocumentMutationKey keyWithUserID:"foo" documentKey:documentKey batchID:2];
+
+  XCTAssertTrue(StartsWith(emptyUserKey, tableKey));
+
+  // While we want a key with whole segments in common be considered a prefix it's vital that
+  // partial segments in common not be prefixes.
+  XCTAssertTrue(StartsWith(fooUserKey, tableKey));
+
+  // Here even though "" is a prefix of "foo" that prefix is within a segment so keys derived from
+  // those segments cannot be prefixes of each other.
+  XCTAssertFalse(StartsWith(fooUserKey, emptyUserKey));
+  XCTAssertFalse(StartsWith(emptyUserKey, fooUserKey));
+
+  // However whole segments in common are prefixes.
+  XCTAssertTrue(StartsWith(foo2Key, tableKey));
+  XCTAssertTrue(StartsWith(foo2Key, fooUserKey));
+}
+
+- (void)testDocumentMutationKeyEncodeDecodeCycle {
+  FSTLevelDBDocumentMutationKey *key = [[FSTLevelDBDocumentMutationKey alloc] init];
+  std::string user("foo");
+
+  NSArray<FSTDocumentKey *> *documentKeys = @[ FSTTestDocKey(@"a/b"), FSTTestDocKey(@"a/b/c/d") ];
+
+  NSArray<NSNumber *> *batchIds = @[ @0, @1, @100, @(INT_MAX - 1), @(INT_MAX) ];
+  for (NSNumber *batchIDNumber in batchIds) {
+    for (FSTDocumentKey *documentKey in documentKeys) {
+      FSTBatchID batchID = [batchIDNumber intValue];
+      auto encoded = [FSTLevelDBDocumentMutationKey keyWithUserID:user
+                                                      documentKey:documentKey
+                                                          batchID:batchID];
+
+      BOOL ok = [key decodeKey:encoded];
+      XCTAssertTrue(ok);
+      XCTAssertEqual(key.userID, user);
+      XCTAssertEqualObjects(key.documentKey, documentKey);
+      XCTAssertEqual(key.batchID, batchID);
+    }
+  }
+}
+
+- (void)testDocumentMutationKeyOrdering {
+  // Different user:
+  FSTAssertKeyLessThan(DocMutationKey(@"1", @"foo/bar", 0), DocMutationKey(@"10", @"foo/bar", 0));
+  FSTAssertKeyLessThan(DocMutationKey(@"1", @"foo/bar", 0), DocMutationKey(@"2", @"foo/bar", 0));
+
+  // Different paths:
+  FSTAssertKeyLessThan(DocMutationKey(@"1", @"foo/bar", 0), DocMutationKey(@"1", @"foo/baz", 0));
+  FSTAssertKeyLessThan(DocMutationKey(@"1", @"foo/bar", 0), DocMutationKey(@"1", @"foo/bar2", 0));
+  FSTAssertKeyLessThan(DocMutationKey(@"1", @"foo/bar", 0),
+                       DocMutationKey(@"1", @"foo/bar/suffix/key", 0));
+  FSTAssertKeyLessThan(DocMutationKey(@"1", @"foo/bar/suffix/key", 0),
+                       DocMutationKey(@"1", @"foo/bar2", 0));
+
+  // Different batchID:
+  FSTAssertKeyLessThan(DocMutationKey(@"1", @"foo/bar", 0), DocMutationKey(@"1", @"foo/bar", 1));
+}
+
+- (void)testDocumentMutationKeyDescription {
+  FSTAssertExpectedKeyDescription([FSTLevelDBDocumentMutationKey keyPrefix],
+                                  @"[document_mutation: incomplete key]");
+
+  FSTAssertExpectedKeyDescription([FSTLevelDBDocumentMutationKey keyPrefixWithUserID:@"user1"],
+                                  @"[document_mutation: userID=user1 incomplete key]");
+
+  auto key = [FSTLevelDBDocumentMutationKey keyPrefixWithUserID:@"user1"
+                                                   resourcePath:FSTTestPath(@"foo/bar")];
+  FSTAssertExpectedKeyDescription(key,
+                                  @"[document_mutation: userID=user1 key=foo/bar incomplete key]");
+
+  key = [FSTLevelDBDocumentMutationKey keyWithUserID:@"user1"
+                                         documentKey:FSTTestDocKey(@"foo/bar")
+                                             batchID:42];
+  FSTAssertExpectedKeyDescription(key, @"[document_mutation: userID=user1 key=foo/bar batchID=42]");
+}
+
+- (void)testTargetGlobalKeyEncodeDecodeCycle {
+  FSTLevelDBTargetGlobalKey *key = [[FSTLevelDBTargetGlobalKey alloc] init];
+
+  auto encoded = [FSTLevelDBTargetGlobalKey key];
+  BOOL ok = [key decodeKey:encoded];
+  XCTAssertTrue(ok);
+}
+
+- (void)testTargetGlobalKeyDescription {
+  FSTAssertExpectedKeyDescription([FSTLevelDBTargetGlobalKey key], @"[target_global:]");
+}
+
+- (void)testTargetKeyEncodeDecodeCycle {
+  FSTLevelDBTargetKey *key = [[FSTLevelDBTargetKey alloc] init];
+  FSTTargetID targetID = 42;
+
+  auto encoded = [FSTLevelDBTargetKey keyWithTargetID:42];
+  BOOL ok = [key decodeKey:encoded];
+  XCTAssertTrue(ok);
+  XCTAssertEqual(key.targetID, targetID);
+}
+
+- (void)testTargetKeyDescription {
+  FSTAssertExpectedKeyDescription([FSTLevelDBTargetKey keyWithTargetID:42],
+                                  @"[target: targetID=42]");
+}
+
+- (void)testQueryTargetKeyEncodeDecodeCycle {
+  FSTLevelDBQueryTargetKey *key = [[FSTLevelDBQueryTargetKey alloc] init];
+  std::string canonicalID("foo");
+  FSTTargetID targetID = 42;
+
+  auto encoded = [FSTLevelDBQueryTargetKey keyWithCanonicalID:canonicalID targetID:42];
+  BOOL ok = [key decodeKey:encoded];
+  XCTAssertTrue(ok);
+  XCTAssertEqual(key.canonicalID, canonicalID);
+  XCTAssertEqual(key.targetID, targetID);
+}
+
+- (void)testQueryKeyDescription {
+  FSTAssertExpectedKeyDescription([FSTLevelDBQueryTargetKey keyWithCanonicalID:"foo" targetID:42],
+                                  @"[query_target: canonicalID=foo targetID=42]");
+}
+
+- (void)testTargetDocumentKeyEncodeDecodeCycle {
+  FSTLevelDBTargetDocumentKey *key = [[FSTLevelDBTargetDocumentKey alloc] init];
+
+  auto encoded =
+      [FSTLevelDBTargetDocumentKey keyWithTargetID:42 documentKey:FSTTestDocKey(@"foo/bar")];
+  BOOL ok = [key decodeKey:encoded];
+  XCTAssertTrue(ok);
+  XCTAssertEqual(key.targetID, 42);
+  XCTAssertEqualObjects(key.documentKey, FSTTestDocKey(@"foo/bar"));
+}
+
+- (void)testTargetDocumentKeyOrdering {
+  // Different targetID:
+  FSTAssertKeyLessThan(TargetDocKey(1, @"foo/bar"), TargetDocKey(2, @"foo/bar"));
+  FSTAssertKeyLessThan(TargetDocKey(2, @"foo/bar"), TargetDocKey(10, @"foo/bar"));
+  FSTAssertKeyLessThan(TargetDocKey(10, @"foo/bar"), TargetDocKey(100, @"foo/bar"));
+  FSTAssertKeyLessThan(TargetDocKey(42, @"foo/bar"), TargetDocKey(100, @"foo/bar"));
+
+  // Different paths:
+  FSTAssertKeyLessThan(TargetDocKey(1, @"foo/bar"), TargetDocKey(1, @"foo/baz"));
+  FSTAssertKeyLessThan(TargetDocKey(1, @"foo/bar"), TargetDocKey(1, @"foo/bar2"));
+  FSTAssertKeyLessThan(TargetDocKey(1, @"foo/bar"), TargetDocKey(1, @"foo/bar/suffix/key"));
+  FSTAssertKeyLessThan(TargetDocKey(1, @"foo/bar/suffix/key"), TargetDocKey(1, @"foo/bar2"));
+}
+
+- (void)testTargetDocumentKeyDescription {
+  auto key = [FSTLevelDBTargetDocumentKey keyWithTargetID:42 documentKey:FSTTestDocKey(@"foo/bar")];
+  XCTAssertEqualObjects([FSTLevelDBKey descriptionForKey:key],
+                        @"[target_document: targetID=42 key=foo/bar]");
+}
+
+- (void)testDocumentTargetKeyEncodeDecodeCycle {
+  FSTLevelDBDocumentTargetKey *key = [[FSTLevelDBDocumentTargetKey alloc] init];
+
+  auto encoded =
+      [FSTLevelDBDocumentTargetKey keyWithDocumentKey:FSTTestDocKey(@"foo/bar") targetID:42];
+  BOOL ok = [key decodeKey:encoded];
+  XCTAssertTrue(ok);
+  XCTAssertEqualObjects(key.documentKey, FSTTestDocKey(@"foo/bar"));
+  XCTAssertEqual(key.targetID, 42);
+}
+
+- (void)testDocumentTargetKeyDescription {
+  auto key = [FSTLevelDBDocumentTargetKey keyWithDocumentKey:FSTTestDocKey(@"foo/bar") targetID:42];
+  XCTAssertEqualObjects([FSTLevelDBKey descriptionForKey:key],
+                        @"[document_target: key=foo/bar targetID=42]");
+}
+
+- (void)testDocumentTargetKeyOrdering {
+  // Different paths:
+  FSTAssertKeyLessThan(DocTargetKey(@"foo/bar", 1), DocTargetKey(@"foo/baz", 1));
+  FSTAssertKeyLessThan(DocTargetKey(@"foo/bar", 1), DocTargetKey(@"foo/bar2", 1));
+  FSTAssertKeyLessThan(DocTargetKey(@"foo/bar", 1), DocTargetKey(@"foo/bar/suffix/key", 1));
+  FSTAssertKeyLessThan(DocTargetKey(@"foo/bar/suffix/key", 1), DocTargetKey(@"foo/bar2", 1));
+
+  // Different targetID:
+  FSTAssertKeyLessThan(DocTargetKey(@"foo/bar", 1), DocTargetKey(@"foo/bar", 2));
+  FSTAssertKeyLessThan(DocTargetKey(@"foo/bar", 2), DocTargetKey(@"foo/bar", 10));
+  FSTAssertKeyLessThan(DocTargetKey(@"foo/bar", 10), DocTargetKey(@"foo/bar", 100));
+  FSTAssertKeyLessThan(DocTargetKey(@"foo/bar", 42), DocTargetKey(@"foo/bar", 100));
+}
+
+- (void)testRemoteDocumentKeyPrefixing {
+  auto tableKey = [FSTLevelDBRemoteDocumentKey keyPrefix];
+
+  XCTAssertTrue(StartsWith(RemoteDocKey(@"foo/bar"), tableKey));
+
+  // This is critical: foo/bar2 should not contain foo/bar.
+  XCTAssertFalse(StartsWith(RemoteDocKey(@"foo/bar2"), RemoteDocKey(@"foo/bar")));
+
+  // Prefixes must be encoded specially
+  XCTAssertFalse(StartsWith(RemoteDocKey(@"foo/bar/baz/quu"), RemoteDocKey(@"foo/bar")));
+  XCTAssertTrue(StartsWith(RemoteDocKey(@"foo/bar/baz/quu"), RemoteDocKeyPrefix(@"foo/bar")));
+  XCTAssertTrue(StartsWith(RemoteDocKeyPrefix(@"foo/bar/baz/quu"), RemoteDocKeyPrefix(@"foo/bar")));
+  XCTAssertTrue(StartsWith(RemoteDocKeyPrefix(@"foo/bar/baz"), RemoteDocKeyPrefix(@"foo/bar")));
+  XCTAssertTrue(StartsWith(RemoteDocKeyPrefix(@"foo/bar"), RemoteDocKeyPrefix(@"foo")));
+}
+
+- (void)testRemoteDocumentKeyOrdering {
+  FSTAssertKeyLessThan(RemoteDocKey(@"foo/bar"), RemoteDocKey(@"foo/bar2"));
+  FSTAssertKeyLessThan(RemoteDocKey(@"foo/bar"), RemoteDocKey(@"foo/bar/suffix/key"));
+}
+
+- (void)testRemoteDocumentKeyEncodeDecodeCycle {
+  FSTLevelDBRemoteDocumentKey *key = [[FSTLevelDBRemoteDocumentKey alloc] init];
+
+  NSArray<NSString *> *paths = @[ @"foo/bar", @"foo/bar2", @"foo/bar/baz/quux" ];
+  for (NSString *path in paths) {
+    auto encoded = RemoteDocKey(path);
+    BOOL ok = [key decodeKey:encoded];
+    XCTAssertTrue(ok);
+    XCTAssertEqualObjects(key.documentKey, FSTTestDocKey(path));
+  }
+}
+
+- (void)testRemoteDocumentKeyDescription {
+  FSTAssertExpectedKeyDescription(
+      [FSTLevelDBRemoteDocumentKey keyWithDocumentKey:FSTTestDocKey(@"foo/bar/baz/quux")],
+      @"[remote_document: key=foo/bar/baz/quux]");
+}
+
+@end
+
+#undef FSTAssertExpectedKeyDescription
+
+NS_ASSUME_NONNULL_END

+ 45 - 0
Firestore/Example/Tests/Local/FSTLevelDBLocalStoreTests.m

@@ -0,0 +1,45 @@
+/*
+ * 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 "Local/FSTLocalStore.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Auth/FSTUser.h"
+#import "Local/FSTLevelDB.h"
+
+#import "FSTLocalStoreTests.h"
+#import "FSTPersistenceTestHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * The tests for FSTLevelDBLocalStore are performed on the FSTLocalStore protocol in
+ * FSTLocalStoreTests. This class is merely responsible for creating a new FSTPersistence
+ * implementation on demand.
+ */
+@interface FSTLevelDBLocalStoreTests : FSTLocalStoreTests
+@end
+
+@implementation FSTLevelDBLocalStoreTests
+
+- (id<FSTPersistence>)persistence {
+  return [FSTPersistenceTestHelpers levelDBPersistence];
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 158 - 0
Firestore/Example/Tests/Local/FSTLevelDBMutationQueueTests.mm

@@ -0,0 +1,158 @@
+/*
+ * 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 "Local/FSTLevelDBMutationQueue.h"
+
+#import <XCTest/XCTest.h>
+#include <leveldb/db.h>
+
+#import "Protos/objc/firestore/local/Mutation.pbobjc.h"
+#import "Auth/FSTUser.h"
+#import "Local/FSTLevelDB.h"
+#import "Local/FSTLevelDBKey.h"
+#import "Local/FSTWriteGroup.h"
+#include "Port/ordered_code.h"
+
+#import "FSTMutationQueueTests.h"
+#import "FSTPersistenceTestHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+using leveldb::DB;
+using leveldb::Slice;
+using leveldb::Status;
+using leveldb::WriteOptions;
+using Firestore::StringView;
+using Firestore::OrderedCode;
+
+// A dummy mutation value, useful for testing code that's known to examine only mutation keys.
+static const char *kDummy = "1";
+
+/**
+ * Most of the tests for FSTLevelDBMutationQueue are performed on the FSTMutationQueue protocol in
+ * FSTMutationQueueTests. This class is responsible for setting up the @a mutationQueue plus any
+ * additional LevelDB-specific tests.
+ */
+@interface FSTLevelDBMutationQueueTests : FSTMutationQueueTests
+@end
+
+/**
+ * Creates a key that's structurally the same as FSTLevelDBMutationKey except it allows for
+ * nonstandard table names.
+ */
+std::string MutationLikeKey(StringView table, StringView userID, FSTBatchID batchID) {
+  std::string key;
+  OrderedCode::WriteString(&key, table);
+  OrderedCode::WriteString(&key, userID);
+  OrderedCode::WriteSignedNumIncreasing(&key, batchID);
+  return key;
+}
+
+@implementation FSTLevelDBMutationQueueTests {
+  FSTLevelDB *_db;
+}
+
+- (void)setUp {
+  [super setUp];
+  _db = [FSTPersistenceTestHelpers levelDBPersistence];
+  self.mutationQueue = [_db mutationQueueForUser:[[FSTUser alloc] initWithUID:@"user"]];
+  self.persistence = _db;
+
+  FSTWriteGroup *group = [self.persistence startGroupWithAction:@"Start MutationQueue"];
+  [self.mutationQueue startWithGroup:group];
+  [self.persistence commitGroup:group];
+}
+
+- (void)testLoadNextBatchID_zeroWhenTotallyEmpty {
+  // Initial seek is invalid
+  XCTAssertEqual([FSTLevelDBMutationQueue loadNextBatchIDFromDB:_db.ptr], 0);
+}
+
+- (void)testLoadNextBatchID_zeroWhenNoMutations {
+  // Initial seek finds no mutations
+  [self setDummyValueForKey:MutationLikeKey("mutationr", "foo", 20)];
+  [self setDummyValueForKey:MutationLikeKey("mutationsa", "foo", 10)];
+  XCTAssertEqual([FSTLevelDBMutationQueue loadNextBatchIDFromDB:_db.ptr], 0);
+}
+
+- (void)testLoadNextBatchID_findsSingleRow {
+  // Seeks off the end of the table altogether
+  [self setDummyValueForKey:[FSTLevelDBMutationKey keyWithUserID:@"foo" batchID:6]];
+
+  XCTAssertEqual([FSTLevelDBMutationQueue loadNextBatchIDFromDB:_db.ptr], 7);
+}
+
+- (void)testLoadNextBatchID_findsSingleRowAmongNonMutations {
+  // Seeks into table following mutations.
+  [self setDummyValueForKey:[FSTLevelDBMutationKey keyWithUserID:@"foo" batchID:6]];
+  [self setDummyValueForKey:MutationLikeKey("mutationsa", "foo", 10)];
+
+  XCTAssertEqual([FSTLevelDBMutationQueue loadNextBatchIDFromDB:_db.ptr], 7);
+}
+
+- (void)testLoadNextBatchID_findsMaxAcrossUsers {
+  [self setDummyValueForKey:[FSTLevelDBMutationKey keyWithUserID:@"fo" batchID:5]];
+  [self setDummyValueForKey:[FSTLevelDBMutationKey keyWithUserID:@"food" batchID:3]];
+
+  [self setDummyValueForKey:[FSTLevelDBMutationKey keyWithUserID:@"foo" batchID:6]];
+  [self setDummyValueForKey:[FSTLevelDBMutationKey keyWithUserID:@"foo" batchID:2]];
+  [self setDummyValueForKey:[FSTLevelDBMutationKey keyWithUserID:@"foo" batchID:1]];
+
+  XCTAssertEqual([FSTLevelDBMutationQueue loadNextBatchIDFromDB:_db.ptr], 7);
+}
+
+- (void)testLoadNextBatchID_onlyFindsMutations {
+  // Write higher-valued batchIDs in nearby "tables"
+  auto tables = @[ @"mutatio", @"mutationsa", @"bears", @"zombies" ];
+  FSTBatchID highBatchID = 5;
+  for (NSString *table in tables) {
+    [self setDummyValueForKey:MutationLikeKey(table, "", highBatchID++)];
+  }
+
+  [self setDummyValueForKey:[FSTLevelDBMutationKey keyWithUserID:@"bar" batchID:3]];
+  [self setDummyValueForKey:[FSTLevelDBMutationKey keyWithUserID:@"bar" batchID:2]];
+  [self setDummyValueForKey:[FSTLevelDBMutationKey keyWithUserID:@"foo" batchID:1]];
+
+  // None of the higher tables should match -- this is the only entry that's in the mutations
+  // table
+  XCTAssertEqual([FSTLevelDBMutationQueue loadNextBatchIDFromDB:_db.ptr], 4);
+}
+
+- (void)testEmptyProtoCanBeUpgraded {
+  // An empty protocol buffer serializes to a zero-length byte buffer.
+  GPBEmpty *empty = [GPBEmpty message];
+  NSData *emptyData = [empty data];
+  XCTAssertEqual(emptyData.length, 0);
+
+  // Choose some other (arbitrary) proto and parse it from the empty message and it should all be
+  // defaults. This shows that empty proto values within the index row value don't pose any future
+  // liability.
+  NSError *error;
+  FSTPBMutationQueue *parsedMessage = [FSTPBMutationQueue parseFromData:emptyData error:&error];
+  XCTAssertNil(error);
+
+  FSTPBMutationQueue *defaultMessage = [FSTPBMutationQueue message];
+  XCTAssertEqual(parsedMessage.lastAcknowledgedBatchId, defaultMessage.lastAcknowledgedBatchId);
+  XCTAssertEqualObjects(parsedMessage.lastStreamToken, defaultMessage.lastStreamToken);
+}
+
+- (void)setDummyValueForKey:(const std::string &)key {
+  _db.ptr->Put(WriteOptions(), key, kDummy);
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 54 - 0
Firestore/Example/Tests/Local/FSTLevelDBQueryCacheTests.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 "Local/FSTLevelDBQueryCache.h"
+
+#import "Local/FSTLevelDB.h"
+
+#import "FSTPersistenceTestHelpers.h"
+#import "FSTQueryCacheTests.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTLevelDBQueryCacheTests : FSTQueryCacheTests
+@end
+
+/**
+ * The tests for FSTLevelDBQueryCache are performed on the FSTQueryCache protocol in
+ * FSTQueryCacheTests. This class is merely responsible for setting up and tearing down the
+ * @a queryCache.
+ */
+@implementation FSTLevelDBQueryCacheTests
+
+- (void)setUp {
+  [super setUp];
+
+  self.persistence = [FSTPersistenceTestHelpers levelDBPersistence];
+  self.queryCache = [self.persistence queryCache];
+  [self.queryCache start];
+}
+
+- (void)tearDown {
+  [self.queryCache shutdown];
+  self.persistence = nil;
+  self.queryCache = nil;
+
+  [super tearDown];
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 78 - 0
Firestore/Example/Tests/Local/FSTLevelDBRemoteDocumentCacheTests.mm

@@ -0,0 +1,78 @@
+/*
+ * 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 "FSTRemoteDocumentCacheTests.h"
+
+#include <leveldb/db.h>
+
+#import "Local/FSTLevelDB.h"
+#import "Local/FSTLevelDBKey.h"
+#include "Port/ordered_code.h"
+
+#import "FSTPersistenceTestHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+using leveldb::WriteOptions;
+using Firestore::OrderedCode;
+
+// A dummy document value, useful for testing code that's known to examine only document keys.
+static const char *kDummy = "1";
+
+/**
+ * The tests for FSTLevelDBRemoteDocumentCache are performed on the FSTRemoteDocumentCache
+ * protocol in FSTRemoteDocumentCacheTests. This class is merely responsible for setting up and
+ * tearing down the @a remoteDocumentCache.
+ */
+@interface FSTLevelDBRemoteDocumentCacheTests : FSTRemoteDocumentCacheTests
+@end
+
+@implementation FSTLevelDBRemoteDocumentCacheTests {
+  FSTLevelDB *_db;
+}
+
+- (void)setUp {
+  [super setUp];
+  _db = [FSTPersistenceTestHelpers levelDBPersistence];
+  self.persistence = _db;
+  self.remoteDocumentCache = [self.persistence remoteDocumentCache];
+
+  // Write a couple dummy rows that should appear before/after the remote_documents table to make
+  // sure the tests are unaffected.
+  [self writeDummyRowWithSegments:@[ @"remote_documentr", @"foo", @"bar" ]];
+  [self writeDummyRowWithSegments:@[ @"remote_documentsa", @"foo", @"bar" ]];
+}
+
+- (void)tearDown {
+  [self.remoteDocumentCache shutdown];
+  self.remoteDocumentCache = nil;
+  self.persistence = nil;
+  _db = nil;
+  [super tearDown];
+}
+
+- (void)writeDummyRowWithSegments:(NSArray<NSString *> *)segments {
+  std::string key;
+  for (NSString *segment in segments) {
+    OrderedCode::WriteString(&key, segment.UTF8String);
+  }
+
+  _db.ptr->Put(WriteOptions(), key, kDummy);
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 181 - 0
Firestore/Example/Tests/Local/FSTLocalSerializerTests.m

@@ -0,0 +1,181 @@
+/*
+ * 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 "Local/FSTLocalSerializer.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Core/FSTQuery.h"
+#import "Core/FSTSnapshotVersion.h"
+#import "Core/FSTTimestamp.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/firestore/local/Target.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/type/Latlng.pbobjc.h"
+#import "Remote/FSTSerializerBeta.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;
+@end
+
+@interface FSTLocalSerializerTests : XCTestCase
+
+@property(nonatomic, strong) FSTLocalSerializer *serializer;
+@property(nonatomic, strong) FSTSerializerBeta *remoteSerializer;
+
+@end
+
+@implementation FSTLocalSerializerTests
+
+- (void)setUp {
+  FSTDatabaseID *databaseID = [FSTDatabaseID databaseIDWithProject:@"p" database:@"d"];
+  self.remoteSerializer = [[FSTSerializerBeta alloc] initWithDatabaseID:databaseID];
+  self.serializer = [[FSTLocalSerializer alloc] initWithRemoteSerializer:self.remoteSerializer];
+}
+
+- (void)testEncodesMutationBatch {
+  FSTMutation *set = FSTTestSetMutation(@"foo/bar", @{ @"a" : @"b", @"num" : @1 });
+  FSTMutation *patch = [[FSTPatchMutation alloc]
+       initWithKey:[FSTDocumentKey keyWithPathString:@"bar/baz"]
+         fieldMask:[[FSTFieldMask alloc] initWithFields:@[ FSTTestFieldPath(@"a") ]]
+             value:FSTTestObjectValue(
+                       @{ @"a" : @"b",
+                          @"num" : @1 })
+      precondition:[FSTPrecondition preconditionWithExists:YES]];
+  FSTMutation *del = FSTTestDeleteMutation(@"baz/quux");
+  FSTTimestamp *writeTime = [FSTTimestamp timestamp];
+  FSTMutationBatch *model = [[FSTMutationBatch alloc] initWithBatchID:42
+                                                       localWriteTime:writeTime
+                                                            mutations:@[ set, patch, del ]];
+
+  GCFSWrite *setProto = [GCFSWrite message];
+  setProto.update.name = @"projects/p/databases/d/documents/foo/bar";
+  [setProto.update.fields addEntriesFromDictionary:@{
+    @"a" : [self.remoteSerializer encodedString:@"b"],
+    @"num" : [self.remoteSerializer encodedInteger:1]
+  }];
+
+  GCFSWrite *patchProto = [GCFSWrite message];
+  patchProto.update.name = @"projects/p/databases/d/documents/bar/baz";
+  [patchProto.update.fields addEntriesFromDictionary:@{
+    @"a" : [self.remoteSerializer encodedString:@"b"],
+    @"num" : [self.remoteSerializer encodedInteger:1]
+  }];
+  [patchProto.updateMask.fieldPathsArray addObjectsFromArray:@[ @"a" ]];
+  patchProto.currentDocument.exists = YES;
+
+  GCFSWrite *delProto = [GCFSWrite message];
+  delProto.delete_p = @"projects/p/databases/d/documents/baz/quux";
+
+  GPBTimestamp *writeTimeProto = [GPBTimestamp message];
+  writeTimeProto.seconds = writeTime.seconds;
+  writeTimeProto.nanos = writeTime.nanos;
+
+  FSTPBWriteBatch *batchProto = [FSTPBWriteBatch message];
+  batchProto.batchId = 42;
+  [batchProto.writesArray addObjectsFromArray:@[ setProto, patchProto, delProto ]];
+  batchProto.localWriteTime = writeTimeProto;
+
+  XCTAssertEqualObjects([self.serializer encodedMutationBatch:model], batchProto);
+  FSTMutationBatch *decoded = [self.serializer decodedMutationBatch:batchProto];
+  XCTAssertEqual(decoded.batchID, model.batchID);
+  XCTAssertEqualObjects(decoded.localWriteTime, model.localWriteTime);
+  XCTAssertEqualObjects(decoded.mutations, model.mutations);
+  XCTAssertEqualObjects([decoded keys], [model keys]);
+}
+
+- (void)testEncodesDocumentAsMaybeDocument {
+  FSTDocument *doc = FSTTestDoc(@"some/path", 42, @{@"foo" : @"bar"}, NO);
+
+  FSTPBMaybeDocument *maybeDocProto = [FSTPBMaybeDocument message];
+  maybeDocProto.document = [GCFSDocument message];
+  maybeDocProto.document.name = @"projects/p/databases/d/documents/some/path";
+  [maybeDocProto.document.fields addEntriesFromDictionary:@{
+    @"foo" : [self.remoteSerializer encodedString:@"bar"],
+  }];
+  maybeDocProto.document.updateTime.seconds = 0;
+  maybeDocProto.document.updateTime.nanos = 42000;
+
+  XCTAssertEqualObjects([self.serializer encodedMaybeDocument:doc], maybeDocProto);
+  FSTMaybeDocument *decoded = [self.serializer decodedMaybeDocument:maybeDocProto];
+  XCTAssertEqualObjects(decoded, doc);
+}
+
+- (void)testEncodesDeletedDocumentAsMaybeDocument {
+  FSTDeletedDocument *deletedDoc = FSTTestDeletedDoc(@"some/path", 42);
+
+  FSTPBMaybeDocument *maybeDocProto = [FSTPBMaybeDocument message];
+  maybeDocProto.noDocument = [FSTPBNoDocument message];
+  maybeDocProto.noDocument.name = @"projects/p/databases/d/documents/some/path";
+  maybeDocProto.noDocument.readTime.seconds = 0;
+  maybeDocProto.noDocument.readTime.nanos = 42000;
+
+  XCTAssertEqualObjects([self.serializer encodedMaybeDocument:deletedDoc], maybeDocProto);
+  FSTMaybeDocument *decoded = [self.serializer decodedMaybeDocument:maybeDocProto];
+  XCTAssertEqualObjects(decoded, deletedDoc);
+}
+
+- (void)testEncodesQueryData {
+  FSTQuery *query = FSTTestQuery(@"room");
+  FSTTargetID targetID = 42;
+  FSTSnapshotVersion *version = FSTTestVersion(1039);
+  NSData *resumeToken = FSTTestResumeTokenFromSnapshotVersion(1039);
+
+  FSTQueryData *queryData = [[FSTQueryData alloc] initWithQuery:query
+                                                       targetID:targetID
+                                                        purpose:FSTQueryPurposeListen
+                                                snapshotVersion:version
+                                                    resumeToken:resumeToken];
+
+  // Let the RPC serializer test various permutations of query serialization.
+  GCFSTarget_QueryTarget *queryTarget = [self.remoteSerializer encodedQueryTarget:query];
+
+  FSTPBTarget *expected = [FSTPBTarget message];
+  expected.targetId = targetID;
+  expected.snapshotVersion.nanos = 1039000;
+  expected.resumeToken = [resumeToken copy];
+  expected.query.parent = queryTarget.parent;
+  expected.query.structuredQuery = queryTarget.structuredQuery;
+
+  XCTAssertEqualObjects([self.serializer encodedQueryData:queryData], expected);
+  FSTQueryData *decoded = [self.serializer decodedQueryData:expected];
+  XCTAssertEqualObjects(decoded, queryData);
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 38 - 0
Firestore/Example/Tests/Local/FSTLocalStoreTests.h

@@ -0,0 +1,38 @@
+/*
+ * 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 <XCTest/XCTest.h>
+
+@class FSTLocalStore;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * These are tests for any implementation of the FSTLocalStore protocol.
+ *
+ * To test a specific implementation of FSTLocalStore:
+ *
+ * + Subclass FSTLocalStoreTests
+ * + override -persistence, creating a new instance of FSTPersistence.
+ */
+@interface FSTLocalStoreTests : XCTestCase
+
+/** Creates and returns an appropriate id<FSTPersistence> implementation. */
+- (id<FSTPersistence>)persistence;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 795 - 0
Firestore/Example/Tests/Local/FSTLocalStoreTests.m

@@ -0,0 +1,795 @@
+/*
+ * 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 "Local/FSTLocalStore.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Auth/FSTUser.h"
+#import "Core/FSTQuery.h"
+#import "Core/FSTTimestamp.h"
+#import "Local/FSTEagerGarbageCollector.h"
+#import "Local/FSTLocalWriteResult.h"
+#import "Local/FSTNoOpGarbageCollector.h"
+#import "Local/FSTPersistence.h"
+#import "Local/FSTQueryData.h"
+#import "Model/FSTDocument.h"
+#import "Model/FSTDocumentKey.h"
+#import "Model/FSTDocumentSet.h"
+#import "Model/FSTMutation.h"
+#import "Model/FSTMutationBatch.h"
+#import "Model/FSTPath.h"
+#import "Remote/FSTRemoteEvent.h"
+#import "Remote/FSTWatchChange.h"
+#import "Util/FSTClasses.h"
+
+#import "FSTHelpers.h"
+#import "FSTImmutableSortedDictionary+Testing.h"
+#import "FSTImmutableSortedSet+Testing.h"
+#import "FSTLocalStoreTests.h"
+#import "FSTWatchChange+Testing.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+/** Creates a document version dictionary mapping the document in @a mutation to @a version. */
+FSTDocumentVersionDictionary *FSTVersionDictionary(FSTMutation *mutation,
+                                                   FSTTestSnapshotVersion version) {
+  FSTDocumentVersionDictionary *result = [FSTDocumentVersionDictionary documentVersionDictionary];
+  result = [result dictionaryBySettingObject:FSTTestVersion(version) forKey:mutation.key];
+  return result;
+}
+
+@interface FSTLocalStoreTests ()
+
+@property(nonatomic, strong, readwrite) id<FSTPersistence> localStorePersistence;
+@property(nonatomic, strong, readwrite) FSTLocalStore *localStore;
+
+@property(nonatomic, strong, readonly) NSMutableArray<FSTMutationBatch *> *batches;
+@property(nonatomic, strong, readwrite, nullable) FSTMaybeDocumentDictionary *lastChanges;
+@property(nonatomic, assign, readwrite) FSTTargetID lastTargetID;
+
+@end
+
+@implementation FSTLocalStoreTests
+
+- (void)setUp {
+  [super setUp];
+
+  if ([self isTestBaseClass]) {
+    return;
+  }
+
+  id<FSTPersistence> persistence = [self persistence];
+  self.localStorePersistence = persistence;
+  id<FSTGarbageCollector> garbageCollector = [[FSTEagerGarbageCollector alloc] init];
+  self.localStore = [[FSTLocalStore alloc] initWithPersistence:persistence
+                                              garbageCollector:garbageCollector
+                                                   initialUser:[FSTUser unauthenticatedUser]];
+  [self.localStore start];
+
+  _batches = [NSMutableArray array];
+  _lastChanges = nil;
+  _lastTargetID = 0;
+}
+
+- (void)tearDown {
+  [self.localStore shutdown];
+  [self.localStorePersistence shutdown];
+
+  [super tearDown];
+}
+
+- (id<FSTPersistence>)persistence {
+  @throw FSTAbstractMethodException();  // NOLINT
+}
+
+/**
+ * Xcode will run tests from any class that extends XCTestCase, but this doesn't work for
+ * FSTLocalStoreTests since it is incomplete without the implementations supplied by its
+ * subclasses.
+ */
+- (BOOL)isTestBaseClass {
+  return [self class] == [FSTLocalStoreTests class];
+}
+
+/** Restarts the local store using the FSTNoOpGarbageCollector instead of the default. */
+- (void)restartWithNoopGarbageCollector {
+  [self.localStore shutdown];
+
+  id<FSTGarbageCollector> garbageCollector = [[FSTNoOpGarbageCollector alloc] init];
+  self.localStore = [[FSTLocalStore alloc] initWithPersistence:self.localStorePersistence
+                                              garbageCollector:garbageCollector
+                                                   initialUser:[FSTUser unauthenticatedUser]];
+  [self.localStore start];
+}
+
+- (void)writeMutation:(FSTMutation *)mutation {
+  [self writeMutations:@[ mutation ]];
+}
+
+- (void)writeMutations:(NSArray<FSTMutation *> *)mutations {
+  FSTLocalWriteResult *result = [self.localStore locallyWriteMutations:mutations];
+  XCTAssertNotNil(result);
+  [self.batches addObject:[[FSTMutationBatch alloc] initWithBatchID:result.batchID
+                                                     localWriteTime:[FSTTimestamp timestamp]
+                                                          mutations:mutations]];
+  self.lastChanges = result.changes;
+}
+
+- (void)applyRemoteEvent:(FSTRemoteEvent *)event {
+  self.lastChanges = [self.localStore applyRemoteEvent:event];
+}
+
+- (void)notifyLocalViewChanges:(FSTLocalViewChanges *)changes {
+  [self.localStore notifyLocalViewChanges:@[ changes ]];
+}
+
+- (void)acknowledgeMutationWithVersion:(FSTTestSnapshotVersion)documentVersion {
+  FSTMutationBatch *batch = [self.batches firstObject];
+  [self.batches removeObjectAtIndex:0];
+  XCTAssertEqual(batch.mutations.count, 1, @"Acknowledging more than one mutation not supported.");
+  FSTSnapshotVersion *version = FSTTestVersion(documentVersion);
+  FSTMutationResult *mutationResult =
+      [[FSTMutationResult alloc] initWithVersion:version transformResults:nil];
+  FSTMutationBatchResult *result = [FSTMutationBatchResult resultWithBatch:batch
+                                                             commitVersion:version
+                                                           mutationResults:@[ mutationResult ]
+                                                               streamToken:nil];
+  self.lastChanges = [self.localStore acknowledgeBatchWithResult:result];
+}
+
+- (void)rejectMutation {
+  FSTMutationBatch *batch = [self.batches firstObject];
+  [self.batches removeObjectAtIndex:0];
+  self.lastChanges = [self.localStore rejectBatchID:batch.batchID];
+}
+
+- (void)allocateQuery:(FSTQuery *)query {
+  FSTQueryData *queryData = [self.localStore allocateQuery:query];
+  self.lastTargetID = queryData.targetID;
+}
+
+- (void)collectGarbage {
+  [self.localStore collectGarbage];
+}
+
+/** Asserts that the last target ID is the given number. */
+#define FSTAssertTargetID(targetID)              \
+  do {                                           \
+    XCTAssertEqual(self.lastTargetID, targetID); \
+  } while (0)
+
+/** Asserts that a the lastChanges contain the docs in the given array. */
+#define FSTAssertChanged(documents)                                                             \
+  XCTAssertNotNil(self.lastChanges);                                                            \
+  do {                                                                                          \
+    FSTMaybeDocumentDictionary *actual = self.lastChanges;                                      \
+    NSArray<FSTMaybeDocument *> *expected = (documents);                                        \
+    XCTAssertEqual(actual.count, expected.count);                                               \
+    NSEnumerator<FSTMaybeDocument *> *enumerator = expected.objectEnumerator;                   \
+    [actual enumerateKeysAndObjectsUsingBlock:^(FSTDocumentKey * key, FSTMaybeDocument * value, \
+                                                BOOL * stop) {                                  \
+      XCTAssertEqualObjects(value, [enumerator nextObject]);                                    \
+    }];                                                                                         \
+    self.lastChanges = nil;                                                                     \
+  } while (0)
+
+/** Asserts that the given keys were removed. */
+#define FSTAssertRemoved(keyPaths)                                                       \
+  XCTAssertNotNil(self.lastChanges);                                                     \
+  do {                                                                                   \
+    FSTMaybeDocumentDictionary *actual = self.lastChanges;                               \
+    XCTAssertEqual(actual.count, keyPaths.count);                                        \
+    NSEnumerator<NSString *> *keyPathEnumerator = keyPaths.objectEnumerator;             \
+    [actual enumerateKeysAndObjectsUsingBlock:^(FSTDocumentKey * actualKey,              \
+                                                FSTMaybeDocument * value, BOOL * stop) { \
+      FSTDocumentKey *expectedKey =                                                      \
+          [FSTDocumentKey keyWithPathString:[keyPathEnumerator nextObject]];             \
+      XCTAssertEqualObjects(actualKey, expectedKey);                                     \
+      XCTAssertTrue([value isKindOfClass:[FSTDeletedDocument class]]);                   \
+    }];                                                                                  \
+    self.lastChanges = nil;                                                              \
+  } while (0)
+
+/** Asserts that the given local store contains the given document. */
+#define FSTAssertContains(document)                                         \
+  do {                                                                      \
+    FSTMaybeDocument *expected = (document);                                \
+    FSTMaybeDocument *actual = [self.localStore readDocument:expected.key]; \
+    XCTAssertEqualObjects(actual, expected);                                \
+  } while (0)
+
+/** Asserts that the given local store does not contain the given document. */
+#define FSTAssertNotContains(keyPathString)                                 \
+  do {                                                                      \
+    FSTDocumentKey *key = [FSTDocumentKey keyWithPathString:keyPathString]; \
+    FSTMaybeDocument *actual = [self.localStore readDocument:key];          \
+    XCTAssertNil(actual);                                                   \
+  } while (0)
+
+- (void)testMutationBatchKeys {
+  if ([self isTestBaseClass]) return;
+
+  FSTMutation *set1 = FSTTestSetMutation(@"foo/bar", @{@"foo" : @"bar"});
+  FSTMutation *set2 = FSTTestSetMutation(@"bar/baz", @{@"bar" : @"baz"});
+  FSTMutationBatch *batch = [[FSTMutationBatch alloc] initWithBatchID:1
+                                                       localWriteTime:[FSTTimestamp timestamp]
+                                                            mutations:@[ set1, set2 ]];
+  FSTDocumentKeySet *keys = [batch keys];
+  XCTAssertEqual(keys.count, 2);
+}
+
+- (void)testHandlesSetMutation {
+  if ([self isTestBaseClass]) return;
+
+  [self writeMutation:FSTTestSetMutation(@"foo/bar", @{@"foo" : @"bar"})];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES));
+
+  [self acknowledgeMutationWithVersion:0];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, NO) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, NO));
+}
+
+- (void)testHandlesSetMutationThenDocument {
+  if ([self isTestBaseClass]) return;
+
+  [self writeMutation:FSTTestSetMutation(@"foo/bar", @{@"foo" : @"bar"})];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES));
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(
+                             FSTTestDoc(@"foo/bar", 2, @{@"it" : @"changed"}, NO), @[ @1 ], @[])];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 2, @{@"foo" : @"bar"}, YES) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 2, @{@"foo" : @"bar"}, YES));
+}
+
+- (void)testHandlesAckThenRejectThenRemoteEvent {
+  if ([self isTestBaseClass]) return;
+
+  // Start a query that requires acks to be held.
+  FSTQuery *query = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  [self allocateQuery:query];
+
+  [self writeMutation:FSTTestSetMutation(@"foo/bar", @{@"foo" : @"bar"})];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES));
+
+  // The last seen version is zero, so this ack must be held.
+  [self acknowledgeMutationWithVersion:1];
+  FSTAssertChanged(@[]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES));
+
+  [self writeMutation:FSTTestSetMutation(@"bar/baz", @{@"bar" : @"baz"})];
+  FSTAssertChanged(@[ FSTTestDoc(@"bar/baz", 0, @{@"bar" : @"baz"}, YES) ]);
+  FSTAssertContains(FSTTestDoc(@"bar/baz", 0, @{@"bar" : @"baz"}, YES));
+
+  [self rejectMutation];
+  FSTAssertRemoved(@[ @"bar/baz" ]);
+  FSTAssertNotContains(@"bar/baz");
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(
+                             FSTTestDoc(@"foo/bar", 2, @{@"it" : @"changed"}, NO), @[ @1 ], @[])];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 2, @{@"it" : @"changed"}, NO) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 2, @{@"it" : @"changed"}, NO));
+  FSTAssertNotContains(@"bar/baz");
+}
+
+- (void)testHandlesDeletedDocumentThenSetMutationThenAck {
+  if ([self isTestBaseClass]) return;
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDeletedDoc(@"foo/bar", 2), @[ @1 ], @[])];
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/bar", 2));
+
+  [self writeMutation:FSTTestSetMutation(@"foo/bar", @{@"foo" : @"bar"})];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES));
+
+  [self acknowledgeMutationWithVersion:3];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, NO) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, NO));
+}
+
+- (void)testHandlesSetMutationThenDeletedDocument {
+  if ([self isTestBaseClass]) return;
+
+  [self writeMutation:FSTTestSetMutation(@"foo/bar", @{@"foo" : @"bar"})];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES) ]);
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDeletedDoc(@"foo/bar", 2), @[ @1 ], @[])];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES));
+}
+
+- (void)testHandlesDocumentThenSetMutationThenAckThenDocument {
+  if ([self isTestBaseClass]) return;
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/bar", 2, @{@"it" : @"base"}, NO),
+                                                  @[ @1 ], @[])];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 2, @{@"it" : @"base"}, NO) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 2, @{@"it" : @"base"}, NO));
+
+  [self writeMutation:FSTTestSetMutation(@"foo/bar", @{@"foo" : @"bar"})];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 2, @{@"foo" : @"bar"}, YES) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 2, @{@"foo" : @"bar"}, YES));
+
+  [self acknowledgeMutationWithVersion:3];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 2, @{@"foo" : @"bar"}, NO) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 2, @{@"foo" : @"bar"}, NO));
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(
+                             FSTTestDoc(@"foo/bar", 3, @{@"it" : @"changed"}, NO), @[ @1 ], @[])];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 3, @{@"it" : @"changed"}, NO) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 3, @{@"it" : @"changed"}, NO));
+}
+
+- (void)testHandlesPatchWithoutPriorDocument {
+  if ([self isTestBaseClass]) return;
+
+  [self writeMutation:FSTTestPatchMutation(@"foo/bar", @{@"foo" : @"bar"}, nil)];
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertNotContains(@"foo/bar");
+
+  [self acknowledgeMutationWithVersion:1];
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertNotContains(@"foo/bar");
+}
+
+- (void)testHandlesPatchMutationThenDocumentThenAck {
+  if ([self isTestBaseClass]) return;
+
+  [self writeMutation:FSTTestPatchMutation(@"foo/bar", @{@"foo" : @"bar"}, nil)];
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertNotContains(@"foo/bar");
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/bar", 1, @{@"it" : @"base"}, NO),
+                                                  @[ @1 ], @[])];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 1, @{@"foo" : @"bar", @"it" : @"base"}, YES) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 1, @{@"foo" : @"bar", @"it" : @"base"}, YES));
+
+  [self acknowledgeMutationWithVersion:2];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 1, @{@"foo" : @"bar", @"it" : @"base"}, NO) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 1, @{@"foo" : @"bar", @"it" : @"base"}, NO));
+}
+
+- (void)testHandlesPatchMutationThenAckThenDocument {
+  if ([self isTestBaseClass]) return;
+
+  [self writeMutation:FSTTestPatchMutation(@"foo/bar", @{@"foo" : @"bar"}, nil)];
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertNotContains(@"foo/bar");
+
+  [self acknowledgeMutationWithVersion:1];
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertNotContains(@"foo/bar");
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/bar", 1, @{@"it" : @"base"}, NO),
+                                                  @[ @1 ], @[])];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 1, @{@"it" : @"base"}, NO) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 1, @{@"it" : @"base"}, NO));
+}
+
+- (void)testHandlesDeleteMutationThenAck {
+  if ([self isTestBaseClass]) return;
+
+  [self writeMutation:FSTTestDeleteMutation(@"foo/bar")];
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/bar", 0));
+
+  [self acknowledgeMutationWithVersion:1];
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/bar", 0));
+}
+
+- (void)testHandlesDocumentThenDeleteMutationThenAck {
+  if ([self isTestBaseClass]) return;
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/bar", 1, @{@"it" : @"base"}, NO),
+                                                  @[ @1 ], @[])];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 1, @{@"it" : @"base"}, NO) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 1, @{@"it" : @"base"}, NO));
+
+  [self writeMutation:FSTTestDeleteMutation(@"foo/bar")];
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/bar", 0));
+
+  [self acknowledgeMutationWithVersion:2];
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/bar", 0));
+}
+
+- (void)testHandlesDeleteMutationThenDocumentThenAck {
+  if ([self isTestBaseClass]) return;
+
+  [self writeMutation:FSTTestDeleteMutation(@"foo/bar")];
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/bar", 0));
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/bar", 1, @{@"it" : @"base"}, NO),
+                                                  @[ @1 ], @[])];
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/bar", 0));
+
+  [self acknowledgeMutationWithVersion:2];
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/bar", 0));
+}
+
+- (void)testHandlesDocumentThenDeletedDocumentThenDocument {
+  if ([self isTestBaseClass]) return;
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/bar", 1, @{@"it" : @"base"}, NO),
+                                                  @[ @1 ], @[])];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 1, @{@"it" : @"base"}, NO) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 1, @{@"it" : @"base"}, NO));
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDeletedDoc(@"foo/bar", 2), @[ @1 ], @[])];
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/bar", 2));
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(
+                             FSTTestDoc(@"foo/bar", 3, @{@"it" : @"changed"}, NO), @[ @1 ], @[])];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 3, @{@"it" : @"changed"}, NO) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 3, @{@"it" : @"changed"}, NO));
+}
+
+- (void)testHandlesSetMutationThenPatchMutationThenDocumentThenAckThenAck {
+  if ([self isTestBaseClass]) return;
+
+  [self writeMutation:FSTTestSetMutation(@"foo/bar", @{@"foo" : @"old"})];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"old"}, YES) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"old"}, YES));
+
+  [self writeMutation:FSTTestPatchMutation(@"foo/bar", @{@"foo" : @"bar"}, nil)];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES));
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/bar", 1, @{@"it" : @"base"}, NO),
+                                                  @[ @1 ], @[])];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 1, @{@"foo" : @"bar"}, YES) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 1, @{@"foo" : @"bar"}, YES));
+
+  [self acknowledgeMutationWithVersion:2];  // delete mutation
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 1, @{@"foo" : @"bar"}, YES) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 1, @{@"foo" : @"bar"}, YES));
+
+  [self acknowledgeMutationWithVersion:3];  // patch mutation
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 1, @{@"foo" : @"bar"}, NO) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 1, @{@"foo" : @"bar"}, NO));
+}
+
+- (void)testHandlesSetMutationAndPatchMutationTogether {
+  if ([self isTestBaseClass]) return;
+
+  [self writeMutations:@[
+    FSTTestSetMutation(@"foo/bar", @{@"foo" : @"old"}),
+    FSTTestPatchMutation(@"foo/bar", @{@"foo" : @"bar"}, nil)
+  ]];
+
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES));
+}
+
+- (void)testHandlesSetMutationThenPatchMutationThenReject {
+  if ([self isTestBaseClass]) return;
+
+  [self writeMutation:FSTTestSetMutation(@"foo/bar", @{@"foo" : @"old"})];
+  [self acknowledgeMutationWithVersion:1];
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"old"}, NO));
+
+  [self writeMutation:FSTTestPatchMutation(@"foo/bar", @{@"foo" : @"bar"}, nil)];
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES));
+
+  [self rejectMutation];
+  FSTAssertChanged(@[ FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"old"}, NO) ]);
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"old"}, NO));
+}
+
+- (void)testHandlesSetMutationsAndPatchMutationOfJustOneTogether {
+  if ([self isTestBaseClass]) return;
+
+  [self writeMutations:@[
+    FSTTestSetMutation(@"foo/bar", @{@"foo" : @"old"}),
+    FSTTestSetMutation(@"bar/baz", @{@"bar" : @"baz"}),
+    FSTTestPatchMutation(@"foo/bar", @{@"foo" : @"bar"}, nil)
+  ]];
+
+  FSTAssertChanged((@[
+    FSTTestDoc(@"bar/baz", 0, @{@"bar" : @"baz"}, YES),
+    FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES)
+  ]));
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES));
+  FSTAssertContains(FSTTestDoc(@"bar/baz", 0, @{@"bar" : @"baz"}, YES));
+}
+
+- (void)testHandlesDeleteMutationThenPatchMutationThenAckThenAck {
+  if ([self isTestBaseClass]) return;
+
+  [self writeMutation:FSTTestDeleteMutation(@"foo/bar")];
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/bar", 0));
+
+  [self writeMutation:FSTTestPatchMutation(@"foo/bar", @{@"foo" : @"bar"}, nil)];
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/bar", 0));
+
+  [self acknowledgeMutationWithVersion:2];  // delete mutation
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/bar", 0));
+
+  [self acknowledgeMutationWithVersion:3];  // patch mutation
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/bar", 0));
+}
+
+- (void)testCollectsGarbageAfterChangeBatchWithNoTargetIDs {
+  if ([self isTestBaseClass]) return;
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDeletedDoc(@"foo/bar", 2), @[ @1 ], @[])];
+  FSTAssertRemoved(@[ @"foo/bar" ]);
+
+  [self collectGarbage];
+  FSTAssertNotContains(@"foo/bar");
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/bar", 2, @{@"foo" : @"bar"}, NO),
+                                                  @[ @1 ], @[])];
+  [self collectGarbage];
+  FSTAssertNotContains(@"foo/bar");
+}
+
+- (void)testCollectsGarbageAfterChangeBatch {
+  if ([self isTestBaseClass]) return;
+
+  FSTQuery *query = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  [self allocateQuery:query];
+  FSTAssertTargetID(2);
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/bar", 2, @{@"foo" : @"bar"}, NO),
+                                                  @[ @2 ], @[])];
+  [self collectGarbage];
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 2, @{@"foo" : @"bar"}, NO));
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/bar", 2, @{@"foo" : @"baz"}, NO),
+                                                  @[], @[ @2 ])];
+  [self collectGarbage];
+
+  FSTAssertNotContains(@"foo/bar");
+}
+
+- (void)testCollectsGarbageAfterAcknowledgedMutation {
+  if ([self isTestBaseClass]) return;
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"old"}, NO),
+                                                  @[ @1 ], @[])];
+  [self writeMutation:FSTTestPatchMutation(@"foo/bar", @{@"foo" : @"bar"}, nil)];
+  [self writeMutation:FSTTestSetMutation(@"foo/bah", @{@"foo" : @"bah"})];
+  [self writeMutation:FSTTestDeleteMutation(@"foo/baz")];
+  [self collectGarbage];
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES));
+  FSTAssertContains(FSTTestDoc(@"foo/bah", 0, @{@"foo" : @"bah"}, YES));
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/baz", 0));
+
+  [self acknowledgeMutationWithVersion:3];
+  [self collectGarbage];
+  FSTAssertNotContains(@"foo/bar");
+  FSTAssertContains(FSTTestDoc(@"foo/bah", 0, @{@"foo" : @"bah"}, YES));
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/baz", 0));
+
+  [self acknowledgeMutationWithVersion:4];
+  [self collectGarbage];
+  FSTAssertNotContains(@"foo/bar");
+  FSTAssertNotContains(@"foo/bah");
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/baz", 0));
+
+  [self acknowledgeMutationWithVersion:5];
+  [self collectGarbage];
+  FSTAssertNotContains(@"foo/bar");
+  FSTAssertNotContains(@"foo/bah");
+  FSTAssertNotContains(@"foo/baz");
+}
+
+- (void)testCollectsGarbageAfterRejectedMutation {
+  if ([self isTestBaseClass]) return;
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"old"}, NO),
+                                                  @[ @1 ], @[])];
+  [self writeMutation:FSTTestPatchMutation(@"foo/bar", @{@"foo" : @"bar"}, nil)];
+  [self writeMutation:FSTTestSetMutation(@"foo/bah", @{@"foo" : @"bah"})];
+  [self writeMutation:FSTTestDeleteMutation(@"foo/baz")];
+  [self collectGarbage];
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES));
+  FSTAssertContains(FSTTestDoc(@"foo/bah", 0, @{@"foo" : @"bah"}, YES));
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/baz", 0));
+
+  [self rejectMutation];  // patch mutation
+  [self collectGarbage];
+  FSTAssertNotContains(@"foo/bar");
+  FSTAssertContains(FSTTestDoc(@"foo/bah", 0, @{@"foo" : @"bah"}, YES));
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/baz", 0));
+
+  [self rejectMutation];  // set mutation
+  [self collectGarbage];
+  FSTAssertNotContains(@"foo/bar");
+  FSTAssertNotContains(@"foo/bah");
+  FSTAssertContains(FSTTestDeletedDoc(@"foo/baz", 0));
+
+  [self rejectMutation];  // delete mutation
+  [self collectGarbage];
+  FSTAssertNotContains(@"foo/bar");
+  FSTAssertNotContains(@"foo/bah");
+  FSTAssertNotContains(@"foo/baz");
+}
+
+- (void)testPinsDocumentsInTheLocalView {
+  if ([self isTestBaseClass]) return;
+
+  FSTQuery *query = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  [self allocateQuery:query];
+  FSTAssertTargetID(2);
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/bar", 1, @{@"foo" : @"bar"}, NO),
+                                                  @[ @2 ], @[])];
+  [self writeMutation:FSTTestSetMutation(@"foo/baz", @{@"foo" : @"baz"})];
+  [self collectGarbage];
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 1, @{@"foo" : @"bar"}, NO));
+  FSTAssertContains(FSTTestDoc(@"foo/baz", 0, @{@"foo" : @"baz"}, YES));
+
+  [self notifyLocalViewChanges:FSTTestViewChanges(query, @[ @"foo/bar", @"foo/baz" ], @[])];
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/bar", 1, @{@"foo" : @"bar"}, NO),
+                                                  @[], @[ @2 ])];
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/baz", 2, @{@"foo" : @"baz"}, NO),
+                                                  @[ @1 ], @[])];
+  [self acknowledgeMutationWithVersion:2];
+  [self collectGarbage];
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 1, @{@"foo" : @"bar"}, NO));
+  FSTAssertContains(FSTTestDoc(@"foo/baz", 2, @{@"foo" : @"baz"}, NO));
+
+  [self notifyLocalViewChanges:FSTTestViewChanges(query, @[], @[ @"foo/bar", @"foo/baz" ])];
+  [self collectGarbage];
+
+  FSTAssertNotContains(@"foo/bar");
+  FSTAssertNotContains(@"foo/baz");
+}
+
+- (void)testThrowsAwayDocumentsWithUnknownTargetIDsImmediately {
+  if ([self isTestBaseClass]) return;
+
+  FSTTargetID targetID = 321;
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/bar", 1, @{}, NO),
+                                                  @[ @(targetID) ], @[])];
+  FSTAssertContains(FSTTestDoc(@"foo/bar", 1, @{}, NO));
+
+  [self collectGarbage];
+  FSTAssertNotContains(@"foo/bar");
+}
+
+- (void)testCanExecuteDocumentQueries {
+  if ([self isTestBaseClass]) return;
+
+  [self.localStore locallyWriteMutations:@[
+    FSTTestSetMutation(@"foo/bar", @{@"foo" : @"bar"}),
+    FSTTestSetMutation(@"foo/baz", @{@"foo" : @"baz"}),
+    FSTTestSetMutation(@"foo/bar/Foo/Bar", @{@"Foo" : @"Bar"})
+  ]];
+  FSTQuery *query = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo", @"bar" ]]];
+  FSTDocumentDictionary *docs = [self.localStore executeQuery:query];
+  XCTAssertEqualObjects([docs values], @[ FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES) ]);
+}
+
+- (void)testCanExecuteCollectionQueries {
+  if ([self isTestBaseClass]) return;
+
+  [self.localStore locallyWriteMutations:@[
+    FSTTestSetMutation(@"fo/bar", @{@"fo" : @"bar"}),
+    FSTTestSetMutation(@"foo/bar", @{@"foo" : @"bar"}),
+    FSTTestSetMutation(@"foo/baz", @{@"foo" : @"baz"}),
+    FSTTestSetMutation(@"foo/bar/Foo/Bar", @{@"Foo" : @"Bar"}),
+    FSTTestSetMutation(@"fooo/blah", @{@"fooo" : @"blah"})
+  ]];
+  FSTQuery *query = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  FSTDocumentDictionary *docs = [self.localStore executeQuery:query];
+  XCTAssertEqualObjects([docs values], (@[
+                          FSTTestDoc(@"foo/bar", 0, @{@"foo" : @"bar"}, YES),
+                          FSTTestDoc(@"foo/baz", 0, @{@"foo" : @"baz"}, YES)
+                        ]));
+}
+
+- (void)testCanExecuteMixedCollectionQueries {
+  if ([self isTestBaseClass]) return;
+
+  FSTQuery *query = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  [self allocateQuery:query];
+  FSTAssertTargetID(2);
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/baz", 10, @{@"a" : @"b"}, NO),
+                                                  @[ @2 ], @[])];
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/bar", 20, @{@"a" : @"b"}, NO),
+                                                  @[ @2 ], @[])];
+
+  [self.localStore locallyWriteMutations:@[ FSTTestSetMutation(@"foo/bonk", @{@"a" : @"b"}) ]];
+
+  FSTDocumentDictionary *docs = [self.localStore executeQuery:query];
+  XCTAssertEqualObjects([docs values], (@[
+                          FSTTestDoc(@"foo/bar", 20, @{@"a" : @"b"}, NO),
+                          FSTTestDoc(@"foo/baz", 10, @{@"a" : @"b"}, NO),
+                          FSTTestDoc(@"foo/bonk", 0, @{@"a" : @"b"}, YES)
+                        ]));
+}
+
+- (void)testPersistsResumeTokens {
+  if ([self isTestBaseClass]) return;
+
+  // This test only works in the absence of the FSTEagerGarbageCollector.
+  [self restartWithNoopGarbageCollector];
+
+  FSTQuery *query = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo", @"bar" ]]];
+  FSTQueryData *queryData = [self.localStore allocateQuery:query];
+  FSTBoxedTargetID *targetID = @(queryData.targetID);
+  NSData *resumeToken = FSTTestResumeTokenFromSnapshotVersion(1000);
+
+  FSTWatchChange *watchChange =
+      [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent
+                                  targetIDs:@[ targetID ]
+                                resumeToken:resumeToken];
+  NSMutableDictionary<FSTBoxedTargetID *, FSTQueryData *> *listens =
+      [NSMutableDictionary dictionary];
+  listens[targetID] = queryData;
+  NSMutableDictionary<FSTBoxedTargetID *, NSNumber *> *pendingResponses =
+      [NSMutableDictionary dictionary];
+  FSTWatchChangeAggregator *aggregator =
+      [[FSTWatchChangeAggregator alloc] initWithSnapshotVersion:FSTTestVersion(1000)
+                                                  listenTargets:listens
+                                         pendingTargetResponses:pendingResponses];
+  [aggregator addWatchChanges:@[ watchChange ]];
+  FSTRemoteEvent *remoteEvent = [aggregator remoteEvent];
+  [self applyRemoteEvent:remoteEvent];
+
+  // Stop listening so that the query should become inactive (but persistent)
+  [self.localStore releaseQuery:query];
+
+  // Should come back with the same resume token
+  FSTQueryData *queryData2 = [self.localStore allocateQuery:query];
+  XCTAssertEqualObjects(queryData2.resumeToken, resumeToken);
+}
+
+- (void)testRemoteDocumentKeysForTarget {
+  if ([self isTestBaseClass]) return;
+  [self restartWithNoopGarbageCollector];
+
+  FSTQuery *query = [FSTQuery queryWithPath:[FSTResourcePath pathWithSegments:@[ @"foo" ]]];
+  [self allocateQuery:query];
+  FSTAssertTargetID(2);
+
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/baz", 10, @{@"a" : @"b"}, NO),
+                                                  @[ @2 ], @[])];
+  [self applyRemoteEvent:FSTTestUpdateRemoteEvent(FSTTestDoc(@"foo/bar", 20, @{@"a" : @"b"}, NO),
+                                                  @[ @2 ], @[])];
+
+  [self.localStore locallyWriteMutations:@[ FSTTestSetMutation(@"foo/bonk", @{@"a" : @"b"}) ]];
+
+  FSTDocumentKeySet *keys = [self.localStore remoteDocumentKeysForTarget:2];
+  FSTAssertEqualSets(keys, (@[ FSTTestDocKey(@"foo/bar"), FSTTestDocKey(@"foo/baz") ]));
+
+  [self restartWithNoopGarbageCollector];
+
+  keys = [self.localStore remoteDocumentKeysForTarget:2];
+  FSTAssertEqualSets(keys, (@[ FSTTestDocKey(@"foo/bar"), FSTTestDocKey(@"foo/baz") ]));
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 44 - 0
Firestore/Example/Tests/Local/FSTMemoryLocalStoreTests.m

@@ -0,0 +1,44 @@
+/*
+ * 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 "Local/FSTLocalStore.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Local/FSTMemoryPersistence.h"
+
+#import "FSTLocalStoreTests.h"
+#import "FSTPersistenceTestHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * This tests the FSTLocalStore with an FSTMemoryPersistence persistence implementation. The tests
+ * are in FSTLocalStoreTests and this class is merely responsible for creating a new FSTPersistence
+ * implementation on demand.
+ */
+@interface FSTMemoryLocalStoreTests : FSTLocalStoreTests
+@end
+
+@implementation FSTMemoryLocalStoreTests
+
+- (id<FSTPersistence>)persistence {
+  return [FSTPersistenceTestHelpers memoryPersistence];
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 42 - 0
Firestore/Example/Tests/Local/FSTMemoryMutationQueueTests.m

@@ -0,0 +1,42 @@
+/*
+ * 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 "Local/FSTMemoryMutationQueue.h"
+
+#import "Auth/FSTUser.h"
+#import "Local/FSTMemoryPersistence.h"
+
+#import "FSTMutationQueueTests.h"
+#import "FSTPersistenceTestHelpers.h"
+
+@interface FSTMemoryMutationQueueTests : FSTMutationQueueTests
+@end
+
+/**
+ * The tests for FSTMemoryMutationQueue are performed on the FSTMutationQueue protocol in
+ * FSTMutationQueueTests. This class is merely responsible for setting up the @a mutationQueue.
+ */
+@implementation FSTMemoryMutationQueueTests
+
+- (void)setUp {
+  [super setUp];
+
+  self.persistence = [FSTPersistenceTestHelpers memoryPersistence];
+  self.mutationQueue =
+      [self.persistence mutationQueueForUser:[[FSTUser alloc] initWithUID:@"user"]];
+}
+
+@end

+ 54 - 0
Firestore/Example/Tests/Local/FSTMemoryQueryCacheTests.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 "Local/FSTMemoryQueryCache.h"
+
+#import "Local/FSTMemoryPersistence.h"
+
+#import "FSTPersistenceTestHelpers.h"
+#import "FSTQueryCacheTests.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTMemoryQueryCacheTests : FSTQueryCacheTests
+@end
+
+/**
+ * The tests for FSTMemoryQueryCache are performed on the FSTQueryCache protocol in
+ * FSTQueryCacheTests. This class is merely responsible for setting up and tearing down the
+ * @a queryCache.
+ */
+@implementation FSTMemoryQueryCacheTests
+
+- (void)setUp {
+  [super setUp];
+
+  self.persistence = [FSTPersistenceTestHelpers memoryPersistence];
+  self.queryCache = [self.persistence queryCache];
+  [self.queryCache start];
+}
+
+- (void)tearDown {
+  [self.queryCache shutdown];
+  self.persistence = nil;
+  self.queryCache = nil;
+
+  [super tearDown];
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 49 - 0
Firestore/Example/Tests/Local/FSTMemoryRemoteDocumentCacheTests.m

@@ -0,0 +1,49 @@
+/*
+ * 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 "Local/FSTMemoryRemoteDocumentCache.h"
+
+#import "Local/FSTMemoryPersistence.h"
+
+#import "FSTPersistenceTestHelpers.h"
+#import "FSTRemoteDocumentCacheTests.h"
+
+@interface FSTMemoryRemoteDocumentCacheTests : FSTRemoteDocumentCacheTests
+@end
+
+/**
+ * The tests for FSTMemoryRemoteDocumentCache are performed on the FSTRemoteDocumentCache
+ * protocol in FSTRemoteDocumentCacheTests. This class is merely responsible for setting up and
+ * tearing down the @a remoteDocumentCache.
+ */
+@implementation FSTMemoryRemoteDocumentCacheTests
+
+- (void)setUp {
+  [super setUp];
+
+  self.persistence = [FSTPersistenceTestHelpers memoryPersistence];
+  self.remoteDocumentCache = [self.persistence remoteDocumentCache];
+}
+
+- (void)tearDown {
+  [self.remoteDocumentCache shutdown];
+  self.persistence = nil;
+  self.remoteDocumentCache = nil;
+
+  [super tearDown];
+}
+
+@end

+ 38 - 0
Firestore/Example/Tests/Local/FSTMutationQueueTests.h

@@ -0,0 +1,38 @@
+/*
+ * 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 <XCTest/XCTest.h>
+
+@protocol FSTMutationQueue;
+@protocol FSTPersistence;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * These are tests for any implementation of the FSTMutationQueue protocol.
+ *
+ * To test a specific implementation of FSTMutationQueue:
+ *
+ * + Subclass FSTMutationQueueTests
+ * + override -setUp, assigning to mutationQueue and persistence
+ * + override -tearDown, cleaning up mutationQueue and persistence
+ */
+@interface FSTMutationQueueTests : XCTestCase
+@property(nonatomic, strong, nullable) id<FSTMutationQueue> mutationQueue;
+@property(nonatomic, strong, nullable) id<FSTPersistence> persistence;
+@end
+
+NS_ASSUME_NONNULL_END

+ 511 - 0
Firestore/Example/Tests/Local/FSTMutationQueueTests.m

@@ -0,0 +1,511 @@
+/*
+ * 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 "FSTMutationQueueTests.h"
+
+#import "Auth/FSTUser.h"
+#import "Core/FSTQuery.h"
+#import "Core/FSTTimestamp.h"
+#import "Local/FSTEagerGarbageCollector.h"
+#import "Local/FSTMutationQueue.h"
+#import "Local/FSTPersistence.h"
+#import "Local/FSTWriteGroup.h"
+#import "Model/FSTMutation.h"
+#import "Model/FSTMutationBatch.h"
+
+#import "FSTHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@implementation FSTMutationQueueTests
+
+- (void)tearDown {
+  [self.mutationQueue shutdown];
+  [self.persistence shutdown];
+  [super tearDown];
+}
+
+/**
+ * Xcode will run tests from any class that extends XCTestCase, but this doesn't work for
+ * FSTMutationQueueTests since it is incomplete without the implementations supplied by its
+ * subclasses.
+ */
+- (BOOL)isTestBaseClass {
+  return [self class] == [FSTMutationQueueTests class];
+}
+
+- (void)testCountBatches {
+  if ([self isTestBaseClass]) return;
+
+  XCTAssertEqual(0, [self batchCount]);
+  XCTAssertTrue([self.mutationQueue isEmpty]);
+
+  FSTMutationBatch *batch1 = [self addMutationBatch];
+  XCTAssertEqual(1, [self batchCount]);
+  XCTAssertFalse([self.mutationQueue isEmpty]);
+
+  FSTMutationBatch *batch2 = [self addMutationBatch];
+  XCTAssertEqual(2, [self batchCount]);
+
+  [self removeMutationBatches:@[ batch2 ]];
+  XCTAssertEqual(1, [self batchCount]);
+
+  [self removeMutationBatches:@[ batch1 ]];
+  XCTAssertEqual(0, [self batchCount]);
+  XCTAssertTrue([self.mutationQueue isEmpty]);
+}
+
+- (void)testAcknowledgeBatchID {
+  if ([self isTestBaseClass]) return;
+
+  // Initial state of an empty queue
+  XCTAssertEqual([self.mutationQueue highestAcknowledgedBatchID], kFSTBatchIDUnknown);
+
+  // Adding mutation batches should not change the highest acked batchID.
+  FSTMutationBatch *batch1 = [self addMutationBatch];
+  FSTMutationBatch *batch2 = [self addMutationBatch];
+  FSTMutationBatch *batch3 = [self addMutationBatch];
+  XCTAssertGreaterThan(batch1.batchID, kFSTBatchIDUnknown);
+  XCTAssertGreaterThan(batch2.batchID, batch1.batchID);
+  XCTAssertGreaterThan(batch3.batchID, batch2.batchID);
+
+  XCTAssertEqual([self.mutationQueue highestAcknowledgedBatchID], kFSTBatchIDUnknown);
+
+  [self acknowledgeBatch:batch1];
+  [self acknowledgeBatch:batch2];
+  XCTAssertEqual([self.mutationQueue highestAcknowledgedBatchID], batch2.batchID);
+
+  [self removeMutationBatches:@[ batch1 ]];
+  XCTAssertEqual([self.mutationQueue highestAcknowledgedBatchID], batch2.batchID);
+
+  [self removeMutationBatches:@[ batch2 ]];
+  XCTAssertEqual([self.mutationQueue highestAcknowledgedBatchID], batch2.batchID);
+
+  // Batch 3 never acknowledged.
+  [self removeMutationBatches:@[ batch3 ]];
+  XCTAssertEqual([self.mutationQueue highestAcknowledgedBatchID], batch2.batchID);
+}
+
+- (void)testAcknowledgeThenRemove {
+  if ([self isTestBaseClass]) return;
+
+  FSTMutationBatch *batch1 = [self addMutationBatch];
+
+  FSTWriteGroup *group = [self.persistence startGroupWithAction:NSStringFromSelector(_cmd)];
+  [self.mutationQueue acknowledgeBatch:batch1 streamToken:nil group:group];
+  [self.mutationQueue removeMutationBatches:@[ batch1 ] group:group];
+  [self.persistence commitGroup:group];
+
+  XCTAssertEqual([self batchCount], 0);
+  XCTAssertEqual([self.mutationQueue highestAcknowledgedBatchID], batch1.batchID);
+}
+
+- (void)testHighestAcknowledgedBatchIDNeverExceedsNextBatchID {
+  if ([self isTestBaseClass]) return;
+
+  FSTMutationBatch *batch1 = [self addMutationBatch];
+  FSTMutationBatch *batch2 = [self addMutationBatch];
+  [self acknowledgeBatch:batch1];
+  [self acknowledgeBatch:batch2];
+  XCTAssertEqual([self.mutationQueue highestAcknowledgedBatchID], batch2.batchID);
+
+  [self removeMutationBatches:@[ batch1, batch2 ]];
+  XCTAssertEqual([self.mutationQueue highestAcknowledgedBatchID], batch2.batchID);
+
+  // Restart the queue so that nextBatchID will be reset.
+  [self.mutationQueue shutdown];
+  self.mutationQueue =
+      [self.persistence mutationQueueForUser:[[FSTUser alloc] initWithUID:@"user"]];
+
+  FSTWriteGroup *group = [self.persistence startGroupWithAction:@"Start MutationQueue"];
+  [self.mutationQueue startWithGroup:group];
+  [self.persistence commitGroup:group];
+
+  // Verify that on restart with an empty queue, nextBatchID falls to a lower value.
+  XCTAssertLessThan(self.mutationQueue.nextBatchID, batch2.batchID);
+
+  // As a result highestAcknowledgedBatchID must also reset lower.
+  XCTAssertEqual([self.mutationQueue highestAcknowledgedBatchID], kFSTBatchIDUnknown);
+
+  // The mutation queue will reset the next batchID after all mutations are removed so adding
+  // another mutation will cause a collision.
+  FSTMutationBatch *newBatch = [self addMutationBatch];
+  XCTAssertEqual(newBatch.batchID, batch1.batchID);
+
+  // Restart the queue with one unacknowledged batch in it.
+  group = [self.persistence startGroupWithAction:@"Start MutationQueue"];
+  [self.mutationQueue startWithGroup:group];
+  [self.persistence commitGroup:group];
+
+  XCTAssertEqual([self.mutationQueue nextBatchID], newBatch.batchID + 1);
+
+  // highestAcknowledgedBatchID must still be kFSTBatchIDUnknown.
+  XCTAssertEqual([self.mutationQueue highestAcknowledgedBatchID], kFSTBatchIDUnknown);
+}
+
+- (void)testLookupMutationBatch {
+  if ([self isTestBaseClass]) return;
+
+  // Searching on an empty queue should not find a non-existent batch
+  FSTMutationBatch *notFound = [self.mutationQueue lookupMutationBatch:42];
+  XCTAssertNil(notFound);
+
+  NSMutableArray<FSTMutationBatch *> *batches = [self createBatches:10];
+  NSArray<FSTMutationBatch *> *removed = [self makeHoles:@[ @2, @6, @7 ] inBatches:batches];
+
+  // After removing, a batch should not be found
+  for (NSUInteger i = 0; i < removed.count; i++) {
+    notFound = [self.mutationQueue lookupMutationBatch:removed[i].batchID];
+    XCTAssertNil(notFound);
+  }
+
+  // Remaining entries should still be found
+  for (FSTMutationBatch *batch in batches) {
+    FSTMutationBatch *found = [self.mutationQueue lookupMutationBatch:batch.batchID];
+    XCTAssertEqual(found.batchID, batch.batchID);
+  }
+
+  // Even on a nonempty queue searching should not find a non-existent batch
+  notFound = [self.mutationQueue lookupMutationBatch:42];
+  XCTAssertNil(notFound);
+}
+
+- (void)testNextMutationBatchAfterBatchID {
+  if ([self isTestBaseClass]) return;
+
+  NSMutableArray<FSTMutationBatch *> *batches = [self createBatches:10];
+
+  // This is an array of successors assuming the removals below will happen:
+  NSArray<FSTMutationBatch *> *afters = @[ batches[3], batches[8], batches[8] ];
+  NSArray<FSTMutationBatch *> *removed = [self makeHoles:@[ @2, @6, @7 ] inBatches:batches];
+
+  for (NSUInteger i = 0; i < batches.count - 1; i++) {
+    FSTMutationBatch *current = batches[i];
+    FSTMutationBatch *next = batches[i + 1];
+    FSTMutationBatch *found = [self.mutationQueue nextMutationBatchAfterBatchID:current.batchID];
+    XCTAssertEqual(found.batchID, next.batchID);
+  }
+
+  for (NSUInteger i = 0; i < removed.count; i++) {
+    FSTMutationBatch *current = removed[i];
+    FSTMutationBatch *next = afters[i];
+    FSTMutationBatch *found = [self.mutationQueue nextMutationBatchAfterBatchID:current.batchID];
+    XCTAssertEqual(found.batchID, next.batchID);
+  }
+
+  FSTMutationBatch *first = batches[0];
+  FSTMutationBatch *found = [self.mutationQueue nextMutationBatchAfterBatchID:first.batchID - 42];
+  XCTAssertEqual(found.batchID, first.batchID);
+
+  FSTMutationBatch *last = batches[batches.count - 1];
+  FSTMutationBatch *notFound = [self.mutationQueue nextMutationBatchAfterBatchID:last.batchID];
+  XCTAssertNil(notFound);
+}
+
+- (void)testAllMutationBatchesThroughBatchID {
+  if ([self isTestBaseClass]) return;
+
+  NSMutableArray<FSTMutationBatch *> *batches = [self createBatches:10];
+  [self makeHoles:@[ @2, @6, @7 ] inBatches:batches];
+
+  NSArray<FSTMutationBatch *> *found, *expected;
+
+  found = [self.mutationQueue allMutationBatchesThroughBatchID:batches[0].batchID - 1];
+  XCTAssertEqualObjects(found, (@[]));
+
+  for (NSUInteger i = 0; i < batches.count; i++) {
+    found = [self.mutationQueue allMutationBatchesThroughBatchID:batches[i].batchID];
+    expected = [batches subarrayWithRange:NSMakeRange(0, i + 1)];
+    XCTAssertEqualObjects(found, expected, @"for index %lu", (unsigned long)i);
+  }
+}
+
+- (void)testAllMutationBatchesAffectingDocumentKey {
+  if ([self isTestBaseClass]) return;
+
+  NSArray<FSTMutation *> *mutations = @[
+    FSTTestSetMutation(@"fob/bar",
+                       @{ @"a" : @1 }),
+    FSTTestSetMutation(@"foo/bar",
+                       @{ @"a" : @1 }),
+    FSTTestPatchMutation(@"foo/bar",
+                         @{ @"b" : @1 }, nil),
+    FSTTestSetMutation(@"foo/bar/suffix/key",
+                       @{ @"a" : @1 }),
+    FSTTestSetMutation(@"foo/baz",
+                       @{ @"a" : @1 }),
+    FSTTestSetMutation(@"food/bar",
+                       @{ @"a" : @1 })
+  ];
+
+  // Store all the mutations.
+  NSMutableArray<FSTMutationBatch *> *batches = [NSMutableArray array];
+  FSTWriteGroup *group = [self.persistence startGroupWithAction:@"New mutation batch"];
+  for (FSTMutation *mutation in mutations) {
+    FSTMutationBatch *batch =
+        [self.mutationQueue addMutationBatchWithWriteTime:[FSTTimestamp timestamp]
+                                                mutations:@[ mutation ]
+                                                    group:group];
+    [batches addObject:batch];
+  }
+  [self.persistence commitGroup:group];
+
+  NSArray<FSTMutationBatch *> *expected = @[ batches[1], batches[2] ];
+  NSArray<FSTMutationBatch *> *matches =
+      [self.mutationQueue allMutationBatchesAffectingDocumentKey:FSTTestDocKey(@"foo/bar")];
+
+  XCTAssertEqualObjects(matches, expected);
+}
+
+- (void)testAllMutationBatchesAffectingQuery {
+  if ([self isTestBaseClass]) return;
+
+  NSArray<FSTMutation *> *mutations = @[
+    FSTTestSetMutation(@"fob/bar",
+                       @{ @"a" : @1 }),
+    FSTTestSetMutation(@"foo/bar",
+                       @{ @"a" : @1 }),
+    FSTTestPatchMutation(@"foo/bar",
+                         @{ @"b" : @1 }, nil),
+    FSTTestSetMutation(@"foo/bar/suffix/key",
+                       @{ @"a" : @1 }),
+    FSTTestSetMutation(@"foo/baz",
+                       @{ @"a" : @1 }),
+    FSTTestSetMutation(@"food/bar",
+                       @{ @"a" : @1 })
+  ];
+
+  // Store all the mutations.
+  NSMutableArray<FSTMutationBatch *> *batches = [NSMutableArray array];
+  FSTWriteGroup *group = [self.persistence startGroupWithAction:@"New mutation batch"];
+  for (FSTMutation *mutation in mutations) {
+    FSTMutationBatch *batch =
+        [self.mutationQueue addMutationBatchWithWriteTime:[FSTTimestamp timestamp]
+                                                mutations:@[ mutation ]
+                                                    group:group];
+    [batches addObject:batch];
+  }
+  [self.persistence commitGroup:group];
+
+  NSArray<FSTMutationBatch *> *expected = @[ batches[1], batches[2], batches[4] ];
+  FSTQuery *query = [FSTQuery queryWithPath:FSTTestPath(@"foo")];
+  NSArray<FSTMutationBatch *> *matches =
+      [self.mutationQueue allMutationBatchesAffectingQuery:query];
+
+  XCTAssertEqualObjects(matches, expected);
+}
+
+- (void)testRemoveMutationBatches {
+  if ([self isTestBaseClass]) return;
+
+  NSMutableArray<FSTMutationBatch *> *batches = [self createBatches:10];
+  FSTMutationBatch *last = batches[batches.count - 1];
+
+  [self removeMutationBatches:@[ batches[0] ]];
+  [batches removeObjectAtIndex:0];
+  XCTAssertEqual([self batchCount], 9);
+
+  NSArray<FSTMutationBatch *> *found;
+
+  found = [self.mutationQueue allMutationBatchesThroughBatchID:last.batchID];
+  XCTAssertEqualObjects(found, batches);
+  XCTAssertEqual(found.count, 9);
+
+  [self removeMutationBatches:@[ batches[0], batches[1], batches[2] ]];
+  [batches removeObjectsInRange:NSMakeRange(0, 3)];
+  XCTAssertEqual([self batchCount], 6);
+
+  found = [self.mutationQueue allMutationBatchesThroughBatchID:last.batchID];
+  XCTAssertEqualObjects(found, batches);
+  XCTAssertEqual(found.count, 6);
+
+  [self removeMutationBatches:@[ batches[batches.count - 1] ]];
+  [batches removeObjectAtIndex:batches.count - 1];
+  XCTAssertEqual([self batchCount], 5);
+
+  found = [self.mutationQueue allMutationBatchesThroughBatchID:last.batchID];
+  XCTAssertEqualObjects(found, batches);
+  XCTAssertEqual(found.count, 5);
+
+  [self removeMutationBatches:@[ batches[3] ]];
+  [batches removeObjectAtIndex:3];
+  XCTAssertEqual([self batchCount], 4);
+
+  [self removeMutationBatches:@[ batches[1] ]];
+  [batches removeObjectAtIndex:1];
+  XCTAssertEqual([self batchCount], 3);
+
+  found = [self.mutationQueue allMutationBatchesThroughBatchID:last.batchID];
+  XCTAssertEqualObjects(found, batches);
+  XCTAssertEqual(found.count, 3);
+  XCTAssertFalse([self.mutationQueue isEmpty]);
+
+  [self removeMutationBatches:batches];
+  found = [self.mutationQueue allMutationBatchesThroughBatchID:last.batchID];
+  XCTAssertEqualObjects(found, @[]);
+  XCTAssertEqual(found.count, 0);
+  XCTAssertTrue([self.mutationQueue isEmpty]);
+}
+
+- (void)testRemoveMutationBatchesEmitsGarbageEvents {
+  if ([self isTestBaseClass]) return;
+
+  FSTEagerGarbageCollector *garbageCollector = [[FSTEagerGarbageCollector alloc] init];
+  [garbageCollector addGarbageSource:self.mutationQueue];
+
+  NSMutableArray<FSTMutationBatch *> *batches = [NSMutableArray array];
+  [batches addObjectsFromArray:@[
+    [self addMutationBatchWithKey:@"foo/bar"],
+    [self addMutationBatchWithKey:@"foo/ba"],
+    [self addMutationBatchWithKey:@"foo/bar2"],
+    [self addMutationBatchWithKey:@"foo/bar"],
+    [self addMutationBatchWithKey:@"foo/bar/suffix/baz"],
+    [self addMutationBatchWithKey:@"bar/baz"],
+  ]];
+
+  [self removeMutationBatches:@[ batches[0] ]];
+  NSSet<FSTDocumentKey *> *garbage = [garbageCollector collectGarbage];
+  FSTAssertEqualSets(garbage, @[]);
+
+  [self removeMutationBatches:@[ batches[1] ]];
+  garbage = [garbageCollector collectGarbage];
+  FSTAssertEqualSets(garbage, @[ FSTTestDocKey(@"foo/ba") ]);
+
+  [self removeMutationBatches:@[ batches[5] ]];
+  garbage = [garbageCollector collectGarbage];
+  FSTAssertEqualSets(garbage, @[ FSTTestDocKey(@"bar/baz") ]);
+
+  [self removeMutationBatches:@[ batches[2], batches[3] ]];
+  garbage = [garbageCollector collectGarbage];
+  FSTAssertEqualSets(garbage, (@[ FSTTestDocKey(@"foo/bar"), FSTTestDocKey(@"foo/bar2") ]));
+
+  [batches addObject:[self addMutationBatchWithKey:@"foo/bar/suffix/baz"]];
+  garbage = [garbageCollector collectGarbage];
+  FSTAssertEqualSets(garbage, @[]);
+
+  [self removeMutationBatches:@[ batches[4], batches[6] ]];
+  garbage = [garbageCollector collectGarbage];
+  FSTAssertEqualSets(garbage, @[ FSTTestDocKey(@"foo/bar/suffix/baz") ]);
+}
+
+- (void)testStreamToken {
+  if ([self isTestBaseClass]) return;
+
+  NSData *streamToken1 = [@"token1" dataUsingEncoding:NSUTF8StringEncoding];
+  NSData *streamToken2 = [@"token2" dataUsingEncoding:NSUTF8StringEncoding];
+
+  FSTWriteGroup *group = [self.persistence startGroupWithAction:@"initial stream token"];
+  [self.mutationQueue setLastStreamToken:streamToken1 group:group];
+  [self.persistence commitGroup:group];
+
+  FSTMutationBatch *batch1 = [self addMutationBatch];
+  [self addMutationBatch];
+
+  XCTAssertEqualObjects([self.mutationQueue lastStreamToken], streamToken1);
+
+  group = [self.persistence startGroupWithAction:@"acknowledgeBatchID"];
+  [self.mutationQueue acknowledgeBatch:batch1 streamToken:streamToken2 group:group];
+  [self.persistence commitGroup:group];
+
+  XCTAssertEqual(self.mutationQueue.highestAcknowledgedBatchID, batch1.batchID);
+  XCTAssertEqualObjects([self.mutationQueue lastStreamToken], streamToken2);
+}
+
+/** Creates a new FSTMutationBatch with the next batch ID and a set of dummy mutations. */
+- (FSTMutationBatch *)addMutationBatch {
+  return [self addMutationBatchWithKey:@"foo/bar"];
+}
+
+/**
+ * Creates a new FSTMutationBatch with the given key, the next batch ID and a set of dummy
+ * mutations.
+ */
+- (FSTMutationBatch *)addMutationBatchWithKey:(NSString *)key {
+  FSTSetMutation *mutation = FSTTestSetMutation(key, @{ @"a" : @1 });
+
+  FSTWriteGroup *group = [self.persistence startGroupWithAction:@"New mutation batch"];
+  FSTMutationBatch *batch =
+      [self.mutationQueue addMutationBatchWithWriteTime:[FSTTimestamp timestamp]
+                                              mutations:@[ mutation ]
+                                                  group:group];
+  [self.persistence commitGroup:group];
+  return batch;
+}
+
+/**
+ * Creates an array of batches containing @a number dummy FSTMutationBatches. Each has a different
+ * batchID.
+ */
+- (NSMutableArray<FSTMutationBatch *> *)createBatches:(int)number {
+  NSMutableArray<FSTMutationBatch *> *batches = [NSMutableArray array];
+
+  for (int i = 0; i < number; i++) {
+    FSTMutationBatch *batch = [self addMutationBatch];
+    [batches addObject:batch];
+  }
+
+  return batches;
+}
+
+/**
+ * Calls -acknowledgeBatch:streamToken:group: on the mutation queue in a new group and commits the
+ * the group.
+ */
+- (void)acknowledgeBatch:(FSTMutationBatch *)batch {
+  FSTWriteGroup *group = [self.persistence startGroupWithAction:@"Ack batchID"];
+  [self.mutationQueue acknowledgeBatch:batch streamToken:nil group:group];
+  [self.persistence commitGroup:group];
+}
+
+/**
+ * Calls -removeMutationBatches:group: on the mutation queue in a new group and commits the group.
+ */
+- (void)removeMutationBatches:(NSArray<FSTMutationBatch *> *)batches {
+  FSTWriteGroup *group = [self.persistence startGroupWithAction:@"Remove mutation batch"];
+  [self.mutationQueue removeMutationBatches:batches group:group];
+  [self.persistence commitGroup:group];
+}
+
+/** Returns the number of mutation batches in the mutation queue. */
+- (NSUInteger)batchCount {
+  return [self.mutationQueue allMutationBatches].count;
+}
+
+/**
+ * Removes entries from from the given @a batches and returns them.
+ *
+ * @param holes An array of indexes in the batches array; in increasing order. Indexes are relative
+ *     to the original state of the batches array, not any intermediate state that might occur.
+ * @param batches The array to mutate, removing entries from it.
+ * @return A new array containing all the entries that were removed from @a batches.
+ */
+- (NSArray<FSTMutationBatch *> *)makeHoles:(NSArray<NSNumber *> *)holes
+                                 inBatches:(NSMutableArray<FSTMutationBatch *> *)batches {
+  NSMutableArray<FSTMutationBatch *> *removed = [NSMutableArray array];
+  for (NSUInteger i = 0; i < holes.count; i++) {
+    NSUInteger index = holes[i].unsignedIntegerValue - i;
+    FSTMutationBatch *batch = batches[index];
+    [self removeMutationBatches:@[ batch ]];
+
+    [batches removeObjectAtIndex:index];
+    [removed addObject:batch];
+  }
+  return removed;
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 40 - 0
Firestore/Example/Tests/Local/FSTPersistenceTestHelpers.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>
+
+@class FSTLevelDB;
+@class FSTMemoryPersistence;
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTPersistenceTestHelpers : NSObject
+
+/**
+ * Creates and starts a new FSTLevelDB instance for testing, destroying any previous contents
+ * if they existed.
+ *
+ * Note that in order to avoid generating a bunch of garbage on the filesystem, the path of the
+ * database is reused. This prevents concurrent running of tests using this database. We may
+ * need to revisit this if we want to parallelize the tests.
+ */
++ (FSTLevelDB *)levelDBPersistence;
+
+/** Creates and starts a new FSTMemoryPersistence instance for testing. */
++ (FSTMemoryPersistence *)memoryPersistence;
+@end
+
+NS_ASSUME_NONNULL_END

+ 72 - 0
Firestore/Example/Tests/Local/FSTPersistenceTestHelpers.m

@@ -0,0 +1,72 @@
+/*
+ * 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 "FSTPersistenceTestHelpers.h"
+
+#import "Local/FSTLevelDB.h"
+#import "Local/FSTLocalSerializer.h"
+#import "Local/FSTMemoryPersistence.h"
+#import "Model/FSTDatabaseID.h"
+#import "Remote/FSTSerializerBeta.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@implementation FSTPersistenceTestHelpers
+
++ (FSTLevelDB *)levelDBPersistence {
+  NSError *error;
+  NSFileManager *files = [NSFileManager defaultManager];
+
+  NSString *dir =
+      [NSTemporaryDirectory() stringByAppendingPathComponent:@"FSTPersistenceTestHelpers"];
+  if ([files fileExistsAtPath:dir]) {
+    // Delete the directory first to ensure isolation between runs.
+    BOOL success = [files removeItemAtPath:dir error:&error];
+    if (!success) {
+      [NSException raise:NSInternalInconsistencyException
+                  format:@"Failed to clean up leveldb path %@: %@", dir, error];
+    }
+  }
+
+  FSTDatabaseID *databaseID = [FSTDatabaseID databaseIDWithProject:@"p" database:@"d"];
+  FSTSerializerBeta *remoteSerializer = [[FSTSerializerBeta alloc] initWithDatabaseID:databaseID];
+  FSTLocalSerializer *serializer =
+      [[FSTLocalSerializer alloc] initWithRemoteSerializer:remoteSerializer];
+  FSTLevelDB *db = [[FSTLevelDB alloc] initWithDirectory:dir serializer:serializer];
+  BOOL success = [db start:&error];
+  if (!success) {
+    [NSException raise:NSInternalInconsistencyException
+                format:@"Failed to create leveldb path %@: %@", dir, error];
+  }
+
+  return db;
+}
+
++ (FSTMemoryPersistence *)memoryPersistence {
+  NSError *error;
+  FSTMemoryPersistence *persistence = [FSTMemoryPersistence persistence];
+  BOOL success = [persistence start:&error];
+  if (!success) {
+    [NSException raise:NSInternalInconsistencyException
+                format:@"Failed to start memory persistence: %@", error];
+  }
+
+  return persistence;
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 47 - 0
Firestore/Example/Tests/Local/FSTQueryCacheTests.h

@@ -0,0 +1,47 @@
+/*
+ * 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 "Local/FSTQueryCache.h"
+
+#import <XCTest/XCTest.h>
+
+@protocol FSTPersistence;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * These are tests for any implementation of the FSTQueryCache protocol.
+ *
+ * To test a specific implementation of FSTQueryCache:
+ *
+ * + Subclass FSTQueryCacheTests
+ * + override -setUp, assigning to queryCache and persistence
+ * + override -tearDown, cleaning up queryCache and persistence
+ */
+@interface FSTQueryCacheTests : XCTestCase
+
+/** The implementation of the query cache to test. */
+@property(nonatomic, strong, nullable) id<FSTQueryCache> queryCache;
+
+/**
+ * The persistence implementation to use while testing the queryCache (e.g. for committing write
+ * groups).
+ */
+@property(nonatomic, strong, nullable) id<FSTPersistence> persistence;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 375 - 0
Firestore/Example/Tests/Local/FSTQueryCacheTests.m

@@ -0,0 +1,375 @@
+/*
+ * 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 "FSTQueryCacheTests.h"
+
+#import "Core/FSTQuery.h"
+#import "Core/FSTSnapshotVersion.h"
+#import "Local/FSTEagerGarbageCollector.h"
+#import "Local/FSTPersistence.h"
+#import "Local/FSTQueryData.h"
+#import "Local/FSTWriteGroup.h"
+#import "Model/FSTDocumentKey.h"
+
+#import "FSTHelpers.h"
+#import "FSTImmutableSortedSet+Testing.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@implementation FSTQueryCacheTests {
+  FSTQuery *_queryRooms;
+}
+
+- (void)setUp {
+  [super setUp];
+
+  _queryRooms = FSTTestQuery(@"rooms");
+}
+
+/**
+ * Xcode will run tests from any class that extends XCTestCase, but this doesn't work for
+ * FSTSpecTests since it is incomplete without the implementations supplied by its subclasses.
+ */
+- (BOOL)isTestBaseClass {
+  return [self class] == [FSTQueryCacheTests class];
+}
+
+- (void)testReadQueryNotInCache {
+  if ([self isTestBaseClass]) return;
+
+  XCTAssertNil([self.queryCache queryDataForQuery:_queryRooms]);
+}
+
+- (void)testSetAndReadAQuery {
+  if ([self isTestBaseClass]) return;
+
+  FSTQueryData *queryData = [self queryDataWithQuery:_queryRooms targetID:1 version:1];
+  [self addQueryData:queryData];
+
+  FSTQueryData *result = [self.queryCache queryDataForQuery:_queryRooms];
+  XCTAssertEqualObjects(result.query, queryData.query);
+  XCTAssertEqual(result.targetID, queryData.targetID);
+  XCTAssertEqualObjects(result.resumeToken, queryData.resumeToken);
+}
+
+- (void)testCanonicalIDCollision {
+  if ([self isTestBaseClass]) return;
+
+  // Type information is currently lost in our canonicalID implementations so this currently an
+  // easy way to force colliding canonicalIDs
+  FSTQuery *q1 = [[FSTQuery queryWithPath:FSTTestPath(@"a")]
+      queryByAddingFilter:FSTTestFilter(@"foo", @"==", @(1))];
+  FSTQuery *q2 = [[FSTQuery queryWithPath:FSTTestPath(@"a")]
+      queryByAddingFilter:FSTTestFilter(@"foo", @"==", @"1")];
+  XCTAssertEqualObjects(q1.canonicalID, q2.canonicalID);
+
+  FSTQueryData *data1 = [self queryDataWithQuery:q1 targetID:1 version:1];
+  [self addQueryData:data1];
+
+  // Using the other query should not return the query cache entry despite equal canonicalIDs.
+  XCTAssertNil([self.queryCache queryDataForQuery:q2]);
+  XCTAssertEqualObjects([self.queryCache queryDataForQuery:q1], data1);
+
+  FSTQueryData *data2 = [self queryDataWithQuery:q2 targetID:2 version:1];
+  [self addQueryData:data2];
+
+  XCTAssertEqualObjects([self.queryCache queryDataForQuery:q1], data1);
+  XCTAssertEqualObjects([self.queryCache queryDataForQuery:q2], data2);
+
+  [self removeQueryData:data1];
+  XCTAssertNil([self.queryCache queryDataForQuery:q1]);
+  XCTAssertEqualObjects([self.queryCache queryDataForQuery:q2], data2);
+
+  [self removeQueryData:data2];
+  XCTAssertNil([self.queryCache queryDataForQuery:q1]);
+  XCTAssertNil([self.queryCache queryDataForQuery:q2]);
+}
+
+- (void)testSetQueryToNewValue {
+  if ([self isTestBaseClass]) return;
+
+  FSTQueryData *queryData1 = [self queryDataWithQuery:_queryRooms targetID:1 version:1];
+  [self addQueryData:queryData1];
+
+  FSTQueryData *queryData2 = [self queryDataWithQuery:_queryRooms targetID:1 version:2];
+  [self addQueryData:queryData2];
+
+  FSTQueryData *result = [self.queryCache queryDataForQuery:_queryRooms];
+  XCTAssertNotEqualObjects(queryData2.resumeToken, queryData1.resumeToken);
+  XCTAssertNotEqualObjects(queryData2.snapshotVersion, queryData1.snapshotVersion);
+  XCTAssertEqualObjects(result.resumeToken, queryData2.resumeToken);
+  XCTAssertEqualObjects(result.snapshotVersion, queryData2.snapshotVersion);
+}
+
+- (void)testRemoveQuery {
+  if ([self isTestBaseClass]) return;
+
+  FSTQueryData *queryData1 = [self queryDataWithQuery:_queryRooms targetID:1 version:1];
+  [self addQueryData:queryData1];
+
+  [self removeQueryData:queryData1];
+
+  FSTQueryData *result = [self.queryCache queryDataForQuery:_queryRooms];
+  XCTAssertNil(result);
+}
+
+- (void)testRemoveNonExistentQuery {
+  if ([self isTestBaseClass]) return;
+
+  FSTQueryData *queryData = [self queryDataWithQuery:_queryRooms targetID:1 version:1];
+
+  // no-op, but make sure it doesn't throw.
+  XCTAssertNoThrow([self removeQueryData:queryData]);
+}
+
+- (void)testRemoveQueryRemovesMatchingKeysToo {
+  if ([self isTestBaseClass]) return;
+
+  FSTQueryData *rooms = [self queryDataWithQuery:_queryRooms targetID:1 version:1];
+  [self addQueryData:rooms];
+
+  FSTDocumentKey *key1 = [FSTDocumentKey keyWithPathString:@"rooms/foo"];
+  FSTDocumentKey *key2 = [FSTDocumentKey keyWithPathString:@"rooms/bar"];
+  [self addMatchingKey:key1 forTargetID:rooms.targetID];
+  [self addMatchingKey:key2 forTargetID:rooms.targetID];
+
+  XCTAssertTrue([self.queryCache containsKey:key1]);
+  XCTAssertTrue([self.queryCache containsKey:key2]);
+
+  [self removeQueryData:rooms];
+  XCTAssertFalse([self.queryCache containsKey:key1]);
+  XCTAssertFalse([self.queryCache containsKey:key2]);
+}
+
+- (void)testAddOrRemoveMatchingKeys {
+  if ([self isTestBaseClass]) return;
+
+  FSTDocumentKey *key = [FSTDocumentKey keyWithPathString:@"foo/bar"];
+
+  XCTAssertFalse([self.queryCache containsKey:key]);
+
+  [self addMatchingKey:key forTargetID:1];
+  XCTAssertTrue([self.queryCache containsKey:key]);
+
+  [self addMatchingKey:key forTargetID:2];
+  XCTAssertTrue([self.queryCache containsKey:key]);
+
+  [self removeMatchingKey:key forTargetID:1];
+  XCTAssertTrue([self.queryCache containsKey:key]);
+
+  [self removeMatchingKey:key forTargetID:2];
+  XCTAssertFalse([self.queryCache containsKey:key]);
+}
+
+- (void)testRemoveMatchingKeysForTargetID {
+  if ([self isTestBaseClass]) return;
+
+  FSTDocumentKey *key1 = [FSTDocumentKey keyWithPathString:@"foo/bar"];
+  FSTDocumentKey *key2 = [FSTDocumentKey keyWithPathString:@"foo/baz"];
+  FSTDocumentKey *key3 = [FSTDocumentKey keyWithPathString:@"foo/blah"];
+
+  [self addMatchingKey:key1 forTargetID:1];
+  [self addMatchingKey:key2 forTargetID:1];
+  [self addMatchingKey:key3 forTargetID:2];
+  XCTAssertTrue([self.queryCache containsKey:key1]);
+  XCTAssertTrue([self.queryCache containsKey:key2]);
+  XCTAssertTrue([self.queryCache containsKey:key3]);
+
+  [self removeMatchingKeysForTargetID:1];
+  XCTAssertFalse([self.queryCache containsKey:key1]);
+  XCTAssertFalse([self.queryCache containsKey:key2]);
+  XCTAssertTrue([self.queryCache containsKey:key3]);
+
+  [self removeMatchingKeysForTargetID:2];
+  XCTAssertFalse([self.queryCache containsKey:key1]);
+  XCTAssertFalse([self.queryCache containsKey:key2]);
+  XCTAssertFalse([self.queryCache containsKey:key3]);
+}
+
+- (void)testRemoveEmitsGarbageEvents {
+  if ([self isTestBaseClass]) return;
+
+  FSTEagerGarbageCollector *garbageCollector = [[FSTEagerGarbageCollector alloc] init];
+  [garbageCollector addGarbageSource:self.queryCache];
+  FSTAssertEqualSets([garbageCollector collectGarbage], @[]);
+
+  FSTQueryData *rooms = [self queryDataWithQuery:FSTTestQuery(@"rooms") targetID:1 version:1];
+  FSTDocumentKey *room1 = [FSTDocumentKey keyWithPathString:@"rooms/bar"];
+  FSTDocumentKey *room2 = [FSTDocumentKey keyWithPathString:@"rooms/foo"];
+  [self addQueryData:rooms];
+  [self addMatchingKey:room1 forTargetID:rooms.targetID];
+  [self addMatchingKey:room2 forTargetID:rooms.targetID];
+
+  FSTQueryData *halls = [self queryDataWithQuery:FSTTestQuery(@"halls") targetID:2 version:1];
+  FSTDocumentKey *hall1 = [FSTDocumentKey keyWithPathString:@"halls/bar"];
+  FSTDocumentKey *hall2 = [FSTDocumentKey keyWithPathString:@"halls/foo"];
+  [self addQueryData:halls];
+  [self addMatchingKey:hall1 forTargetID:halls.targetID];
+  [self addMatchingKey:hall2 forTargetID:halls.targetID];
+
+  FSTAssertEqualSets([garbageCollector collectGarbage], @[]);
+
+  [self removeMatchingKey:room1 forTargetID:rooms.targetID];
+  FSTAssertEqualSets([garbageCollector collectGarbage], @[ room1 ]);
+
+  [self removeQueryData:rooms];
+  FSTAssertEqualSets([garbageCollector collectGarbage], @[ room2 ]);
+
+  [self removeMatchingKeysForTargetID:halls.targetID];
+  FSTAssertEqualSets([garbageCollector collectGarbage], (@[ hall1, hall2 ]));
+}
+
+- (void)testMatchingKeysForTargetID {
+  if ([self isTestBaseClass]) return;
+
+  FSTDocumentKey *key1 = [FSTDocumentKey keyWithPathString:@"foo/bar"];
+  FSTDocumentKey *key2 = [FSTDocumentKey keyWithPathString:@"foo/baz"];
+  FSTDocumentKey *key3 = [FSTDocumentKey keyWithPathString:@"foo/blah"];
+
+  [self addMatchingKey:key1 forTargetID:1];
+  [self addMatchingKey:key2 forTargetID:1];
+  [self addMatchingKey:key3 forTargetID:2];
+
+  FSTAssertEqualSets([self.queryCache matchingKeysForTargetID:1], (@[ key1, key2 ]));
+  FSTAssertEqualSets([self.queryCache matchingKeysForTargetID:2], @[ key3 ]);
+
+  [self addMatchingKey:key1 forTargetID:2];
+  FSTAssertEqualSets([self.queryCache matchingKeysForTargetID:1], (@[ key1, key2 ]));
+  FSTAssertEqualSets([self.queryCache matchingKeysForTargetID:2], (@[ key1, key3 ]));
+}
+
+- (void)testHighestTargetID {
+  if ([self isTestBaseClass]) return;
+
+  XCTAssertEqual([self.queryCache highestTargetID], 0);
+
+  FSTQueryData *query1 = [[FSTQueryData alloc] initWithQuery:FSTTestQuery(@"rooms")
+                                                    targetID:1
+                                                     purpose:FSTQueryPurposeListen];
+  FSTDocumentKey *key1 = [FSTDocumentKey keyWithPathString:@"rooms/bar"];
+  FSTDocumentKey *key2 = [FSTDocumentKey keyWithPathString:@"rooms/foo"];
+  [self addQueryData:query1];
+  [self addMatchingKey:key1 forTargetID:1];
+  [self addMatchingKey:key2 forTargetID:1];
+
+  FSTQueryData *query2 = [[FSTQueryData alloc] initWithQuery:FSTTestQuery(@"halls")
+                                                    targetID:2
+                                                     purpose:FSTQueryPurposeListen];
+  FSTDocumentKey *key3 = [FSTDocumentKey keyWithPathString:@"halls/foo"];
+  [self addQueryData:query2];
+  [self addMatchingKey:key3 forTargetID:2];
+  XCTAssertEqual([self.queryCache highestTargetID], 2);
+
+  // TargetIDs never come down.
+  [self removeQueryData:query2];
+  XCTAssertEqual([self.queryCache highestTargetID], 2);
+
+  // A query with an empty result set still counts.
+  FSTQueryData *query3 = [[FSTQueryData alloc] initWithQuery:FSTTestQuery(@"garages")
+                                                    targetID:42
+                                                     purpose:FSTQueryPurposeListen];
+  [self addQueryData:query3];
+  XCTAssertEqual([self.queryCache highestTargetID], 42);
+
+  [self removeQueryData:query1];
+  XCTAssertEqual([self.queryCache highestTargetID], 42);
+
+  [self removeQueryData:query3];
+  XCTAssertEqual([self.queryCache highestTargetID], 42);
+
+  // Verify that the highestTargetID even survives restarts.
+  [self.queryCache shutdown];
+  self.queryCache = [self.persistence queryCache];
+  [self.queryCache start];
+  XCTAssertEqual([self.queryCache highestTargetID], 42);
+}
+
+- (void)testLastRemoteSnapshotVersion {
+  if ([self isTestBaseClass]) return;
+
+  XCTAssertEqualObjects([self.queryCache lastRemoteSnapshotVersion],
+                        [FSTSnapshotVersion noVersion]);
+
+  // Can set the snapshot version.
+  FSTWriteGroup *group = [self.persistence startGroupWithAction:@"setLastRemoteSnapshotVersion"];
+  [self.queryCache setLastRemoteSnapshotVersion:FSTTestVersion(42) group:group];
+  [self.persistence commitGroup:group];
+  XCTAssertEqualObjects([self.queryCache lastRemoteSnapshotVersion], FSTTestVersion(42));
+
+  // Snapshot version persists restarts.
+  self.queryCache = [self.persistence queryCache];
+  [self.queryCache start];
+  XCTAssertEqualObjects([self.queryCache lastRemoteSnapshotVersion], FSTTestVersion(42));
+}
+
+#pragma mark - Helpers
+
+/**
+ * Creates a new FSTQueryData object from the given parameters, synthesizing a resume token from
+ * the snapshot version.
+ */
+- (FSTQueryData *)queryDataWithQuery:(FSTQuery *)query
+                            targetID:(FSTTargetID)targetID
+                             version:(FSTTestSnapshotVersion)version {
+  NSData *resumeToken = FSTTestResumeTokenFromSnapshotVersion(version);
+  return [[FSTQueryData alloc] initWithQuery:query
+                                    targetID:targetID
+                                     purpose:FSTQueryPurposeListen
+                             snapshotVersion:FSTTestVersion(version)
+                                 resumeToken:resumeToken];
+}
+
+/** Adds the given query data to the queryCache under test, committing immediately. */
+- (void)addQueryData:(FSTQueryData *)queryData {
+  FSTWriteGroup *group = [self.persistence startGroupWithAction:@"addQueryData"];
+  [self.queryCache addQueryData:queryData group:group];
+  [self.persistence commitGroup:group];
+}
+
+/** Removes the given query data from the queryCache under test, committing immediately. */
+- (void)removeQueryData:(FSTQueryData *)queryData {
+  FSTWriteGroup *group = [self.persistence startGroupWithAction:@"removeQueryData"];
+  [self.queryCache removeQueryData:queryData group:group];
+  [self.persistence commitGroup:group];
+}
+
+- (void)addMatchingKey:(FSTDocumentKey *)key forTargetID:(FSTTargetID)targetID {
+  FSTDocumentKeySet *keys = [FSTDocumentKeySet keySet];
+  keys = [keys setByAddingObject:key];
+
+  FSTWriteGroup *group = [self.persistence startGroupWithAction:@"addMatchingKeys"];
+  [self.queryCache addMatchingKeys:keys forTargetID:targetID group:group];
+  [self.persistence commitGroup:group];
+}
+
+- (void)removeMatchingKey:(FSTDocumentKey *)key forTargetID:(FSTTargetID)targetID {
+  FSTDocumentKeySet *keys = [FSTDocumentKeySet keySet];
+  keys = [keys setByAddingObject:key];
+
+  FSTWriteGroup *group = [self.persistence startGroupWithAction:@"removeMatchingKeys"];
+  [self.queryCache removeMatchingKeys:keys forTargetID:targetID group:group];
+  [self.persistence commitGroup:group];
+}
+
+- (void)removeMatchingKeysForTargetID:(FSTTargetID)targetID {
+  FSTWriteGroup *group = [self.persistence startGroupWithAction:@"removeMatchingKeysForTargetID"];
+  [self.queryCache removeMatchingKeysForTargetID:targetID group:group];
+  [self.persistence commitGroup:group];
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 84 - 0
Firestore/Example/Tests/Local/FSTReferenceSetTests.m

@@ -0,0 +1,84 @@
+/*
+ * 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 "Local/FSTReferenceSet.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Model/FSTDocumentKey.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTReferenceSetTests : XCTestCase
+@end
+
+@implementation FSTReferenceSetTests
+
+- (void)testAddOrRemoveReferences {
+  FSTDocumentKey *key = [FSTDocumentKey keyWithPathString:@"foo/bar"];
+
+  FSTReferenceSet *referenceSet = [[FSTReferenceSet alloc] init];
+  XCTAssertTrue([referenceSet isEmpty]);
+  XCTAssertFalse([referenceSet containsKey:key]);
+
+  [referenceSet addReferenceToKey:key forID:1];
+  XCTAssertTrue([referenceSet containsKey:key]);
+  XCTAssertFalse([referenceSet isEmpty]);
+
+  [referenceSet addReferenceToKey:key forID:2];
+  XCTAssertTrue([referenceSet containsKey:key]);
+
+  [referenceSet removeReferenceToKey:key forID:1];
+  XCTAssertTrue([referenceSet containsKey:key]);
+
+  [referenceSet removeReferenceToKey:key forID:3];
+  XCTAssertTrue([referenceSet containsKey:key]);
+
+  [referenceSet removeReferenceToKey:key forID:2];
+  XCTAssertFalse([referenceSet containsKey:key]);
+  XCTAssertTrue([referenceSet isEmpty]);
+}
+
+- (void)testRemoveAllReferencesForTargetID {
+  FSTDocumentKey *key1 = [FSTDocumentKey keyWithPathString:@"foo/bar"];
+  FSTDocumentKey *key2 = [FSTDocumentKey keyWithPathString:@"foo/baz"];
+  FSTDocumentKey *key3 = [FSTDocumentKey keyWithPathString:@"foo/blah"];
+  FSTReferenceSet *referenceSet = [[FSTReferenceSet alloc] init];
+
+  [referenceSet addReferenceToKey:key1 forID:1];
+  [referenceSet addReferenceToKey:key2 forID:1];
+  [referenceSet addReferenceToKey:key3 forID:2];
+  XCTAssertFalse([referenceSet isEmpty]);
+  XCTAssertTrue([referenceSet containsKey:key1]);
+  XCTAssertTrue([referenceSet containsKey:key2]);
+  XCTAssertTrue([referenceSet containsKey:key3]);
+
+  [referenceSet removeReferencesForID:1];
+  XCTAssertFalse([referenceSet isEmpty]);
+  XCTAssertFalse([referenceSet containsKey:key1]);
+  XCTAssertFalse([referenceSet containsKey:key2]);
+  XCTAssertTrue([referenceSet containsKey:key3]);
+
+  [referenceSet removeReferencesForID:2];
+  XCTAssertTrue([referenceSet isEmpty]);
+  XCTAssertFalse([referenceSet containsKey:key1]);
+  XCTAssertFalse([referenceSet containsKey:key2]);
+  XCTAssertFalse([referenceSet containsKey:key3]);
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 39 - 0
Firestore/Example/Tests/Local/FSTRemoteDocumentCacheTests.h

@@ -0,0 +1,39 @@
+/*
+ * 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 "Local/FSTRemoteDocumentCache.h"
+
+#import <XCTest/XCTest.h>
+
+@protocol FSTPersistence;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * These are tests for any implementation of the FSTRemoteDocumentCache protocol.
+ *
+ * To test a specific implementation of FSTRemoteDocumentCache:
+ *
+ * + Subclass FSTRemoteDocumentCacheTests
+ * + override -setUp, assigning to remoteDocumentCache and persistence
+ * + override -tearDown, cleaning up remoteDocumentCache and persistence
+ */
+@interface FSTRemoteDocumentCacheTests : XCTestCase
+@property(nonatomic, strong, nullable) id<FSTRemoteDocumentCache> remoteDocumentCache;
+@property(nonatomic, strong, nullable) id<FSTPersistence> persistence;
+@end
+
+NS_ASSUME_NONNULL_END

+ 151 - 0
Firestore/Example/Tests/Local/FSTRemoteDocumentCacheTests.m

@@ -0,0 +1,151 @@
+/*
+ * 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 "FSTRemoteDocumentCacheTests.h"
+
+#import "Core/FSTQuery.h"
+#import "Local/FSTPersistence.h"
+#import "Local/FSTWriteGroup.h"
+#import "Model/FSTDocument.h"
+#import "Model/FSTDocumentKey.h"
+#import "Model/FSTDocumentSet.h"
+
+#import "FSTHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+static NSString *const kDocPath = @"a/b";
+static NSString *const kLongDocPath = @"a/b/c/d/e/f";
+static const int kVersion = 42;
+
+@implementation FSTRemoteDocumentCacheTests {
+  NSDictionary<NSString *, id> *_kDocData;
+}
+
+- (void)setUp {
+  [super setUp];
+
+  // essentially a constant, but can't be a compile-time one.
+  _kDocData = @{ @"a" : @1, @"b" : @2 };
+}
+
+- (void)testReadDocumentNotInCache {
+  if (!self.remoteDocumentCache) return;
+
+  XCTAssertNil([self readEntryAtPath:kDocPath]);
+}
+
+// Helper for next two tests.
+- (void)setAndReadADocumentAtPath:(NSString *)path {
+  FSTDocument *written = [self setTestDocumentAtPath:path];
+  FSTMaybeDocument *read = [self readEntryAtPath:path];
+  XCTAssertEqualObjects(read, written);
+}
+
+- (void)testSetAndReadADocument {
+  if (!self.remoteDocumentCache) return;
+
+  [self setAndReadADocumentAtPath:kDocPath];
+}
+
+- (void)testSetAndReadADocumentAtDeepPath {
+  if (!self.remoteDocumentCache) return;
+
+  [self setAndReadADocumentAtPath:kLongDocPath];
+}
+
+- (void)testSetAndReadDeletedDocument {
+  if (!self.remoteDocumentCache) return;
+
+  FSTDeletedDocument *deletedDoc = FSTTestDeletedDoc(kDocPath, kVersion);
+  [self addEntry:deletedDoc];
+
+  XCTAssertEqualObjects([self readEntryAtPath:kDocPath], deletedDoc);
+}
+
+- (void)testSetDocumentToNewValue {
+  if (!self.remoteDocumentCache) return;
+
+  [self setTestDocumentAtPath:kDocPath];
+  FSTDocument *newDoc = FSTTestDoc(kDocPath, kVersion, @{ @"data" : @2 }, NO);
+  [self addEntry:newDoc];
+  XCTAssertEqualObjects([self readEntryAtPath:kDocPath], newDoc);
+}
+
+- (void)testRemoveDocument {
+  if (!self.remoteDocumentCache) return;
+
+  [self setTestDocumentAtPath:kDocPath];
+  [self removeEntryAtPath:kDocPath];
+
+  XCTAssertNil([self readEntryAtPath:kDocPath]);
+}
+
+- (void)testRemoveNonExistentDocument {
+  if (!self.remoteDocumentCache) return;
+
+  // no-op, but make sure it doesn't throw.
+  XCTAssertNoThrow([self removeEntryAtPath:kDocPath]);
+}
+
+// TODO(mikelehen): Write more elaborate tests once we have more elaborate implementations.
+- (void)testDocumentsMatchingQuery {
+  if (!self.remoteDocumentCache) return;
+
+  [self setTestDocumentAtPath:@"a/1"];
+  [self setTestDocumentAtPath:@"b/1"];
+  [self setTestDocumentAtPath:@"b/2"];
+  [self setTestDocumentAtPath:@"c/1"];
+
+  FSTQuery *query = [FSTQuery queryWithPath:FSTTestPath(@"b")];
+  FSTDocumentDictionary *results = [self.remoteDocumentCache documentsMatchingQuery:query];
+  NSArray *expected =
+      @[ FSTTestDoc(@"b/1", kVersion, _kDocData, NO), FSTTestDoc(@"b/2", kVersion, _kDocData, NO) ];
+  for (FSTDocument *doc in expected) {
+    XCTAssertEqualObjects([results objectForKey:doc.key], doc);
+  }
+
+  // TODO(mikelehen): Perhaps guard against extra documents in the result set once our
+  // implementations are smarter.
+}
+
+#pragma mark - Helpers
+
+- (FSTDocument *)setTestDocumentAtPath:(NSString *)path {
+  FSTDocument *doc = FSTTestDoc(path, kVersion, _kDocData, NO);
+  [self addEntry:doc];
+  return doc;
+}
+
+- (void)addEntry:(FSTMaybeDocument *)maybeDoc {
+  FSTWriteGroup *group = [self.persistence startGroupWithAction:@"addEntry"];
+  [self.remoteDocumentCache addEntry:maybeDoc group:group];
+  [self.persistence commitGroup:group];
+}
+
+- (FSTMaybeDocument *_Nullable)readEntryAtPath:(NSString *)path {
+  return [self.remoteDocumentCache entryForKey:FSTTestDocKey(path)];
+}
+
+- (void)removeEntryAtPath:(NSString *)path {
+  FSTWriteGroup *group = [self.persistence startGroupWithAction:@"removeEntryAtPath"];
+  [self.remoteDocumentCache removeEntryForKey:FSTTestDocKey(path) group:group];
+  [self.persistence commitGroup:group];
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 113 - 0
Firestore/Example/Tests/Local/FSTRemoteDocumentChangeBufferTests.m

@@ -0,0 +1,113 @@
+/*
+ * 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 "Local/FSTRemoteDocumentChangeBuffer.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Local/FSTLevelDB.h"
+#import "Local/FSTRemoteDocumentCache.h"
+#import "Model/FSTDocument.h"
+
+#import "FSTHelpers.h"
+#import "FSTPersistenceTestHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTRemoteDocumentChangeBufferTests : XCTestCase
+@end
+
+@implementation FSTRemoteDocumentChangeBufferTests {
+  FSTLevelDB *_db;
+  id<FSTRemoteDocumentCache> _remoteDocumentCache;
+  FSTRemoteDocumentChangeBuffer *_remoteDocumentBuffer;
+
+  FSTMaybeDocument *_kInitialADoc;
+  FSTMaybeDocument *_kInitialBDoc;
+}
+
+- (void)setUp {
+  [super setUp];
+
+  _db = [FSTPersistenceTestHelpers levelDBPersistence];
+  _remoteDocumentCache = [_db remoteDocumentCache];
+
+  // Add a couple initial items to the cache.
+  FSTWriteGroup *group = [_db startGroupWithAction:@"Add initial docs."];
+  _kInitialADoc = FSTTestDoc(@"coll/a", 42, @{@"test" : @"data"}, NO);
+  [_remoteDocumentCache addEntry:_kInitialADoc group:group];
+
+  _kInitialBDoc =
+      [FSTDeletedDocument documentWithKey:FSTTestDocKey(@"coll/b") version:FSTTestVersion(314)];
+  [_remoteDocumentCache addEntry:_kInitialBDoc group:group];
+  [_db commitGroup:group];
+
+  _remoteDocumentBuffer =
+      [FSTRemoteDocumentChangeBuffer changeBufferWithCache:_remoteDocumentCache];
+}
+
+- (void)tearDown {
+  _remoteDocumentBuffer = nil;
+  _remoteDocumentCache = nil;
+  _db = nil;
+
+  [super tearDown];
+}
+
+- (void)testReadUnchangedEntry {
+  XCTAssertEqualObjects([_remoteDocumentBuffer entryForKey:FSTTestDocKey(@"coll/a")],
+                        _kInitialADoc);
+}
+
+- (void)testAddEntryAndReadItBack {
+  FSTMaybeDocument *newADoc = FSTTestDoc(@"coll/a", 43, @{@"new" : @"data"}, NO);
+  [_remoteDocumentBuffer addEntry:newADoc];
+  XCTAssertEqualObjects([_remoteDocumentBuffer entryForKey:FSTTestDocKey(@"coll/a")], newADoc);
+
+  // B should still be unchanged.
+  XCTAssertEqualObjects([_remoteDocumentBuffer entryForKey:FSTTestDocKey(@"coll/b")],
+                        _kInitialBDoc);
+}
+
+- (void)testApplyChanges {
+  FSTMaybeDocument *newADoc = FSTTestDoc(@"coll/a", 43, @{@"new" : @"data"}, NO);
+  [_remoteDocumentBuffer addEntry:newADoc];
+  XCTAssertEqualObjects([_remoteDocumentBuffer entryForKey:FSTTestDocKey(@"coll/a")], newADoc);
+
+  // Reading directly against the cache should still yield the old result.
+  XCTAssertEqualObjects([_remoteDocumentCache entryForKey:FSTTestDocKey(@"coll/a")], _kInitialADoc);
+
+  FSTWriteGroup *group = [_db startGroupWithAction:@"Apply changes"];
+  [_remoteDocumentBuffer applyToWriteGroup:group];
+  [_db commitGroup:group];
+
+  // Reading against the cache should now yield the new result.
+  XCTAssertEqualObjects([_remoteDocumentCache entryForKey:FSTTestDocKey(@"coll/a")], newADoc);
+}
+
+- (void)testMethodsThrowAfterApply {
+  FSTWriteGroup *group = [_db startGroupWithAction:@"Apply changes"];
+  [_remoteDocumentBuffer applyToWriteGroup:group];
+  [_db commitGroup:group];
+
+  XCTAssertThrows([_remoteDocumentBuffer entryForKey:FSTTestDocKey(@"coll/a")]);
+  XCTAssertThrows([_remoteDocumentBuffer addEntry:_kInitialADoc]);
+  XCTAssertThrows([_remoteDocumentBuffer applyToWriteGroup:group]);
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 121 - 0
Firestore/Example/Tests/Local/FSTWriteGroupTests.mm

@@ -0,0 +1,121 @@
+/*
+ * 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 "Local/FSTWriteGroup.h"
+
+#import <XCTest/XCTest.h>
+#include <leveldb/db.h>
+
+#import "Protos/objc/firestore/local/Mutation.pbobjc.h"
+#import "Local/FSTLevelDB.h"
+#import "Local/FSTLevelDBKey.h"
+
+#import "FSTPersistenceTestHelpers.h"
+
+using leveldb::ReadOptions;
+using leveldb::Status;
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTWriteGroupTests : XCTestCase
+@end
+
+@implementation FSTWriteGroupTests {
+  FSTLevelDB *_db;
+}
+
+- (void)setUp {
+  [super setUp];
+
+  _db = [FSTPersistenceTestHelpers levelDBPersistence];
+}
+
+- (void)tearDown {
+  _db = nil;
+
+  [super tearDown];
+}
+
+- (void)testCommit {
+  std::string key = [FSTLevelDBMutationKey keyWithUserID:"user1" batchID:42];
+  FSTPBWriteBatch *message = [FSTPBWriteBatch message];
+  message.batchId = 42;
+
+  // This is a test that shows that committing an empty group does not fail. There are no side
+  // effects to verify though.
+  FSTWriteGroup *group = [_db startGroupWithAction:@"Empty commit"];
+  XCTAssertNoThrow([_db commitGroup:group]);
+
+  group = [_db startGroupWithAction:@"Put"];
+  [group setMessage:message forKey:key];
+
+  std::string value;
+  Status status = _db.ptr->Get(ReadOptions(), key, &value);
+  XCTAssertTrue(status.IsNotFound());
+
+  [_db commitGroup:group];
+  status = _db.ptr->Get(ReadOptions(), key, &value);
+  XCTAssertTrue(status.ok());
+
+  group = [_db startGroupWithAction:@"Delete"];
+  [group removeMessageForKey:key];
+  status = _db.ptr->Get(ReadOptions(), key, &value);
+  XCTAssertTrue(status.ok());
+
+  [_db commitGroup:group];
+  status = _db.ptr->Get(ReadOptions(), key, &value);
+  XCTAssertTrue(status.IsNotFound());
+}
+
+- (void)testDescription {
+  std::string key = [FSTLevelDBMutationKey keyWithUserID:"user1" batchID:42];
+  FSTPBWriteBatch *message = [FSTPBWriteBatch message];
+  message.batchId = 42;
+
+  FSTWriteGroup *group = [FSTWriteGroup groupWithAction:@"Action"];
+  XCTAssertEqualObjects([group description], @"<FSTWriteGroup for Action: 0 changes (0 bytes):>");
+
+  [group setMessage:message forKey:key];
+  XCTAssertEqualObjects([group description],
+                        @"<FSTWriteGroup for Action: 1 changes (2 bytes):\n"
+                         "  - Put [mutation: userID=user1 batchID=42] (2 bytes)>");
+
+  [group removeMessageForKey:key];
+  XCTAssertEqualObjects([group description],
+                        @"<FSTWriteGroup for Action: 2 changes (2 bytes):\n"
+                         "  - Put [mutation: userID=user1 batchID=42] (2 bytes)\n"
+                         "  - Delete [mutation: userID=user1 batchID=42]>");
+}
+
+- (void)testCommittingWrongGroupThrows {
+  // If you don't create the group through persistence, it should throw.
+  FSTWriteGroup *group = [FSTWriteGroup groupWithAction:@"group"];
+  XCTAssertThrows([_db commitGroup:group]);
+}
+
+- (void)testCommittingTwiceThrows {
+  FSTWriteGroup *group = [_db startGroupWithAction:@"group"];
+  [_db commitGroup:group];
+  XCTAssertThrows([_db commitGroup:group]);
+}
+
+- (void)testNestingGroupsThrows {
+  [_db startGroupWithAction:@"group1"];
+  XCTAssertThrows([_db startGroupWithAction:@"group2"]);
+}
+@end
+
+NS_ASSUME_NONNULL_END

+ 45 - 0
Firestore/Example/Tests/Model/FSTDatabaseIDTests.m

@@ -0,0 +1,45 @@
+/*
+ * 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 "Model/FSTDatabaseID.h"
+
+#import <XCTest/XCTest.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTDatabaseIDTests : XCTestCase
+@end
+
+@implementation FSTDatabaseIDTests
+
+- (void)testConstructor {
+  FSTDatabaseID *databaseID = [FSTDatabaseID databaseIDWithProject:@"p" database:@"d"];
+  XCTAssertEqualObjects(databaseID.projectID, @"p");
+  XCTAssertEqualObjects(databaseID.databaseID, @"d");
+  XCTAssertFalse([databaseID isDefaultDatabase]);
+}
+
+- (void)testDefaultDatabase {
+  FSTDatabaseID *databaseID =
+      [FSTDatabaseID databaseIDWithProject:@"p" database:kDefaultDatabaseID];
+  XCTAssertEqualObjects(databaseID.projectID, @"p");
+  XCTAssertEqualObjects(databaseID.databaseID, @"(default)");
+  XCTAssertTrue([databaseID isDefaultDatabase]);
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 60 - 0
Firestore/Example/Tests/Model/FSTDocumentKeyTests.m

@@ -0,0 +1,60 @@
+/*
+ * 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 "Model/FSTDocumentKey.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Model/FSTPath.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTDocumentKeyTests : XCTestCase
+@end
+
+@implementation FSTDocumentKeyTests
+
+- (void)testConstructor {
+  FSTResourcePath *path =
+      [FSTResourcePath pathWithSegments:@[ @"rooms", @"firestore", @"messages", @"1" ]];
+  FSTDocumentKey *key = [FSTDocumentKey keyWithPath:path];
+  XCTAssertEqual(path, key.path);
+}
+
+- (void)testComparison {
+  FSTDocumentKey *key1 = [FSTDocumentKey keyWithSegments:@[ @"a", @"b", @"c", @"d" ]];
+  FSTDocumentKey *key2 = [FSTDocumentKey keyWithSegments:@[ @"a", @"b", @"c", @"d" ]];
+  FSTDocumentKey *key3 = [FSTDocumentKey keyWithSegments:@[ @"x", @"y", @"z", @"w" ]];
+  XCTAssertTrue([key1 isEqualToKey:key2]);
+  XCTAssertFalse([key1 isEqualToKey:key3]);
+
+  FSTDocumentKey *empty = [FSTDocumentKey keyWithSegments:@[]];
+  FSTDocumentKey *a = [FSTDocumentKey keyWithSegments:@[ @"a", @"a" ]];
+  FSTDocumentKey *b = [FSTDocumentKey keyWithSegments:@[ @"b", @"b" ]];
+  FSTDocumentKey *ab = [FSTDocumentKey keyWithSegments:@[ @"a", @"a", @"b", @"b" ]];
+
+  XCTAssertEqual(NSOrderedAscending, [empty compare:a]);
+  XCTAssertEqual(NSOrderedAscending, [a compare:b]);
+  XCTAssertEqual(NSOrderedAscending, [a compare:ab]);
+
+  XCTAssertEqual(NSOrderedDescending, [a compare:empty]);
+  XCTAssertEqual(NSOrderedDescending, [b compare:a]);
+  XCTAssertEqual(NSOrderedDescending, [ab compare:a]);
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 142 - 0
Firestore/Example/Tests/Model/FSTDocumentSetTests.m

@@ -0,0 +1,142 @@
+/*
+ * 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 "Model/FSTDocumentSet.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Model/FSTDocument.h"
+
+#import "FSTHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTDocumentSetTests : XCTestCase
+@end
+
+@implementation FSTDocumentSetTests {
+  NSComparator _comp;
+  FSTDocument *_doc1;
+  FSTDocument *_doc2;
+  FSTDocument *_doc3;
+}
+
+- (void)setUp {
+  [super setUp];
+
+  _comp = FSTTestDocComparator(@"sort");
+  _doc1 = FSTTestDoc(@"docs/1", 0, @{ @"sort" : @2 }, NO);
+  _doc2 = FSTTestDoc(@"docs/2", 0, @{ @"sort" : @3 }, NO);
+  _doc3 = FSTTestDoc(@"docs/3", 0, @{ @"sort" : @1 }, NO);
+}
+
+- (void)testCount {
+  XCTAssertEqual([FSTTestDocSet(_comp, @[]) count], 0);
+  XCTAssertEqual([FSTTestDocSet(_comp, @[ _doc1, _doc2, _doc3 ]) count], 3);
+}
+
+- (void)testHasKey {
+  FSTDocumentSet *set = FSTTestDocSet(_comp, @[ _doc1, _doc2 ]);
+
+  XCTAssertTrue([set containsKey:_doc1.key]);
+  XCTAssertTrue([set containsKey:_doc2.key]);
+  XCTAssertFalse([set containsKey:_doc3.key]);
+}
+
+- (void)testDocumentForKey {
+  FSTDocumentSet *set = FSTTestDocSet(_comp, @[ _doc1, _doc2 ]);
+
+  XCTAssertEqualObjects([set documentForKey:_doc1.key], _doc1);
+  XCTAssertEqualObjects([set documentForKey:_doc2.key], _doc2);
+  XCTAssertNil([set documentForKey:_doc3.key]);
+}
+
+- (void)testFirstAndLastDocument {
+  FSTDocumentSet *set = FSTTestDocSet(_comp, @[]);
+  XCTAssertNil([set firstDocument]);
+  XCTAssertNil([set lastDocument]);
+
+  set = FSTTestDocSet(_comp, @[ _doc1, _doc2, _doc3 ]);
+  XCTAssertEqualObjects([set firstDocument], _doc3);
+  XCTAssertEqualObjects([set lastDocument], _doc2);
+}
+
+- (void)testKeepsDocumentsInTheRightOrder {
+  FSTDocumentSet *set = FSTTestDocSet(_comp, @[ _doc1, _doc2, _doc3 ]);
+  XCTAssertEqualObjects([[set documentEnumerator] allObjects], (@[ _doc3, _doc1, _doc2 ]));
+}
+
+- (void)testPredecessorDocumentForKey {
+  FSTDocumentSet *set = FSTTestDocSet(_comp, @[ _doc1, _doc2, _doc3 ]);
+
+  XCTAssertNil([set predecessorDocumentForKey:_doc3.key]);
+  XCTAssertEqualObjects([set predecessorDocumentForKey:_doc1.key], _doc3);
+  XCTAssertEqualObjects([set predecessorDocumentForKey:_doc2.key], _doc1);
+}
+
+- (void)testDeletes {
+  FSTDocumentSet *set = FSTTestDocSet(_comp, @[ _doc1, _doc2, _doc3 ]);
+
+  FSTDocumentSet *setWithoutDoc1 = [set documentSetByRemovingKey:_doc1.key];
+  XCTAssertEqualObjects([[setWithoutDoc1 documentEnumerator] allObjects], (@[ _doc3, _doc2 ]));
+  XCTAssertEqual([setWithoutDoc1 count], 2);
+
+  // Original remains unchanged
+  XCTAssertEqualObjects([[set documentEnumerator] allObjects], (@[ _doc3, _doc1, _doc2 ]));
+
+  FSTDocumentSet *setWithoutDoc3 = [setWithoutDoc1 documentSetByRemovingKey:_doc3.key];
+  XCTAssertEqualObjects([[setWithoutDoc3 documentEnumerator] allObjects], (@[ _doc2 ]));
+  XCTAssertEqual([setWithoutDoc3 count], 1);
+}
+
+- (void)testUpdates {
+  FSTDocumentSet *set = FSTTestDocSet(_comp, @[ _doc1, _doc2, _doc3 ]);
+
+  FSTDocument *doc2Prime = FSTTestDoc(@"docs/2", 0, @{ @"sort" : @9 }, NO);
+
+  set = [set documentSetByAddingDocument:doc2Prime];
+  XCTAssertEqual([set count], 3);
+  XCTAssertEqualObjects([set documentForKey:doc2Prime.key], doc2Prime);
+  XCTAssertEqualObjects([[set documentEnumerator] allObjects], (@[ _doc3, _doc1, doc2Prime ]));
+}
+
+- (void)testAddsDocsWithEqualComparisonValues {
+  FSTDocument *doc4 = FSTTestDoc(@"docs/4", 0, @{ @"sort" : @2 }, NO);
+
+  FSTDocumentSet *set = FSTTestDocSet(_comp, @[ _doc1, doc4 ]);
+  XCTAssertEqualObjects([[set documentEnumerator] allObjects], (@[ _doc1, doc4 ]));
+}
+
+- (void)testIsEqual {
+  FSTDocumentSet *set1 = FSTTestDocSet(FSTDocumentComparatorByKey, @[ _doc1, _doc2, _doc3 ]);
+  FSTDocumentSet *set2 = FSTTestDocSet(FSTDocumentComparatorByKey, @[ _doc1, _doc2, _doc3 ]);
+  XCTAssertEqualObjects(set1, set1);
+  XCTAssertEqualObjects(set1, set2);
+  XCTAssertNotEqualObjects(set1, nil);
+
+  FSTDocumentSet *sortedSet1 = FSTTestDocSet(_comp, @[ _doc1, _doc2, _doc3 ]);
+  FSTDocumentSet *sortedSet2 = FSTTestDocSet(_comp, @[ _doc1, _doc2, _doc3 ]);
+  XCTAssertEqualObjects(sortedSet1, sortedSet1);
+  XCTAssertEqualObjects(sortedSet1, sortedSet2);
+  XCTAssertNotEqualObjects(sortedSet1, nil);
+
+  FSTDocumentSet *shortSet = FSTTestDocSet(FSTDocumentComparatorByKey, @[ _doc1, _doc2 ]);
+  XCTAssertNotEqualObjects(set1, shortSet);
+  XCTAssertNotEqualObjects(set1, sortedSet1);
+}
+@end
+
+NS_ASSUME_NONNULL_END

+ 101 - 0
Firestore/Example/Tests/Model/FSTDocumentTests.m

@@ -0,0 +1,101 @@
+/*
+ * 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 "Model/FSTDocument.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Core/FSTSnapshotVersion.h"
+#import "Model/FSTDocumentKey.h"
+#import "Model/FSTFieldValue.h"
+#import "Model/FSTPath.h"
+
+#import "FSTHelpers.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTDocumentTests : XCTestCase
+@end
+
+@implementation FSTDocumentTests
+
+- (void)testConstructor {
+  FSTDocumentKey *key = [FSTDocumentKey keyWithPathString:@"messages/first"];
+  FSTSnapshotVersion *version = FSTTestVersion(1);
+  FSTObjectValue *data = FSTTestObjectValue(@{ @"a" : @1 });
+  FSTDocument *doc =
+      [FSTDocument documentWithData:data key:key version:version hasLocalMutations:NO];
+
+  XCTAssertEqualObjects(doc.key, [FSTDocumentKey keyWithPathString:@"messages/first"]);
+  XCTAssertEqualObjects(doc.version, version);
+  XCTAssertEqualObjects(doc.data, data);
+  XCTAssertEqual(doc.hasLocalMutations, NO);
+}
+
+- (void)testExtractsFields {
+  FSTDocumentKey *key = [FSTDocumentKey keyWithPathString:@"rooms/eros"];
+  FSTSnapshotVersion *version = FSTTestVersion(1);
+  FSTObjectValue *data = FSTTestObjectValue(@{
+    @"desc" : @"Discuss all the project related stuff",
+    @"owner" : @{@"name" : @"Jonny", @"title" : @"scallywag"}
+  });
+  FSTDocument *doc =
+      [FSTDocument documentWithData:data key:key version:version hasLocalMutations:NO];
+
+  XCTAssertEqualObjects([doc fieldForPath:FSTTestFieldPath(@"desc")],
+                        [FSTStringValue stringValue:@"Discuss all the project related stuff"]);
+  XCTAssertEqualObjects([doc fieldForPath:FSTTestFieldPath(@"owner.title")],
+                        [FSTStringValue stringValue:@"scallywag"]);
+}
+
+- (void)testIsEqual {
+  FSTDocumentKey *key1 = [FSTDocumentKey keyWithPathString:@"messages/first"];
+  FSTDocumentKey *key2 = [FSTDocumentKey keyWithPathString:@"messages/second"];
+  FSTObjectValue *data1 = FSTTestObjectValue(@{ @"a" : @1 });
+  FSTObjectValue *data2 = FSTTestObjectValue(@{ @"b" : @1 });
+  FSTSnapshotVersion *version1 = FSTTestVersion(1);
+
+  FSTDocument *doc1 =
+      [FSTDocument documentWithData:data1 key:key1 version:version1 hasLocalMutations:NO];
+  FSTDocument *doc2 =
+      [FSTDocument documentWithData:data1 key:key1 version:version1 hasLocalMutations:NO];
+
+  XCTAssertEqualObjects(doc1, doc2);
+  XCTAssertEqualObjects(
+      doc1, [FSTDocument documentWithData:FSTTestObjectValue(
+                                              @{ @"a" : @1 })
+                                      key:[FSTDocumentKey keyWithPathString:@"messages/first"]
+                                  version:version1
+                        hasLocalMutations:NO]);
+
+  FSTSnapshotVersion *version2 = FSTTestVersion(2);
+  XCTAssertNotEqualObjects(
+      doc1, [FSTDocument documentWithData:data2 key:key1 version:version1 hasLocalMutations:NO]);
+  XCTAssertNotEqualObjects(
+      doc1, [FSTDocument documentWithData:data1 key:key2 version:version1 hasLocalMutations:NO]);
+  XCTAssertNotEqualObjects(
+      doc1, [FSTDocument documentWithData:data1 key:key1 version:version2 hasLocalMutations:NO]);
+  XCTAssertNotEqualObjects(
+      doc1, [FSTDocument documentWithData:data1 key:key1 version:version1 hasLocalMutations:YES]);
+
+  XCTAssertEqualObjects(
+      [FSTDocument documentWithData:data1 key:key1 version:version1 hasLocalMutations:YES],
+      [FSTDocument documentWithData:data1 key:key1 version:version1 hasLocalMutations:5]);
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 576 - 0
Firestore/Example/Tests/Model/FSTFieldValueTests.m

@@ -0,0 +1,576 @@
+/*
+ * 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 "Model/FSTFieldValue.h"
+
+#import <XCTest/XCTest.h>
+
+#import "API/FIRFirestore+Internal.h"
+#import "API/FSTUserDataConverter.h"
+#import "Core/FSTTimestamp.h"
+#import "Firestore/FIRGeoPoint.h"
+#import "Model/FSTDatabaseID.h"
+#import "Model/FSTFieldValue.h"
+#import "Model/FSTPath.h"
+
+#import "FSTHelpers.h"
+
+/** Helper to wrap the values in a set of equality groups using FSTTestFieldValue(). */
+NSArray *FSTWrapGroups(NSArray *groups) {
+  NSMutableArray *wrapped = [NSMutableArray array];
+  for (NSArray<id> *group in groups) {
+    NSMutableArray *wrappedGroup = [NSMutableArray array];
+    for (id value in group) {
+      FSTFieldValue *wrappedValue;
+      // Server Timestamp values can't be parsed directly, so we have a couple predefined sentinel
+      // strings that can be used instead.
+      if ([value isEqual:@"server-timestamp-1"]) {
+        wrappedValue = [FSTServerTimestampValue
+            serverTimestampValueWithLocalWriteTime:FSTTestTimestamp(2016, 5, 20, 10, 20, 0)];
+      } else if ([value isEqual:@"server-timestamp-2"]) {
+        wrappedValue = [FSTServerTimestampValue
+            serverTimestampValueWithLocalWriteTime:FSTTestTimestamp(2016, 10, 21, 15, 32, 0)];
+      } else if ([value isKindOfClass:[FSTDocumentKeyReference class]]) {
+        // We directly convert these here so that the databaseIDs can be different.
+        FSTDocumentKeyReference *reference = (FSTDocumentKeyReference *)value;
+        wrappedValue =
+            [FSTReferenceValue referenceValue:reference.key databaseID:reference.databaseID];
+      } else {
+        wrappedValue = FSTTestFieldValue(value);
+      }
+      [wrappedGroup addObject:wrappedValue];
+    }
+    [wrapped addObject:wrappedGroup];
+  }
+  return wrapped;
+}
+
+@interface FSTFieldValueTests : XCTestCase
+@end
+
+@implementation FSTFieldValueTests {
+  NSDate *date1;
+  NSDate *date2;
+}
+
+- (void)setUp {
+  [super setUp];
+  // Create a couple date objects for use in tests.
+  date1 = FSTTestDate(2016, 5, 20, 10, 20, 0);
+  date2 = FSTTestDate(2016, 10, 21, 15, 32, 0);
+}
+
+- (void)testWrapIntegers {
+  NSArray *values = @[
+    @(INT_MIN), @(-1), @0, @1, @2, @(UCHAR_MAX), @(INT_MAX),  // Standard integers
+    @(LONG_MIN), @(LONG_MAX), @(LLONG_MIN), @(LLONG_MAX)      // Larger values
+  ];
+  for (id value in values) {
+    FSTFieldValue *wrapped = FSTTestFieldValue(value);
+    XCTAssertEqualObjects([wrapped class], [FSTIntegerValue class]);
+    XCTAssertEqualObjects([wrapped value], @([value longLongValue]));
+  }
+}
+
+- (void)testWrapsDoubles {
+  // Note that 0x1.0p-1074 is a hex floating point literal representing the minimum subnormal
+  // number: <https://en.wikipedia.org/wiki/Denormal_number>.
+  NSArray *values = @[
+    @(-INFINITY), @(-DBL_MAX), @(LLONG_MIN * -1.0), @(-1.1), @(-0x1.0p-1074), @(-0.0), @(0.0),
+    @(0x1.0p-1074), @(DBL_MIN), @(1.1), @(LLONG_MAX * 1.0), @(DBL_MAX), @(INFINITY)
+  ];
+  for (id value in values) {
+    FSTFieldValue *wrapped = FSTTestFieldValue(value);
+    XCTAssertEqualObjects([wrapped class], [FSTDoubleValue class]);
+    XCTAssertEqualObjects([wrapped value], value);
+  }
+}
+
+- (void)testWrapsNilAndNSNull {
+  FSTNullValue *nullValue = [FSTNullValue nullValue];
+  XCTAssertEqual(FSTTestFieldValue(nil), nullValue);
+  XCTAssertEqual(FSTTestFieldValue([NSNull null]), nullValue);
+  XCTAssertEqual([nullValue value], [NSNull null]);
+}
+
+- (void)testWrapsBooleans {
+  NSArray *values = @[ @YES, @NO, [NSNumber numberWithChar:1], [NSNumber numberWithChar:0] ];
+  for (id value in values) {
+    FSTFieldValue *wrapped = FSTTestFieldValue(value);
+    XCTAssertEqualObjects([wrapped class], [FSTBooleanValue class]);
+    XCTAssertEqualObjects([wrapped value], value);
+  }
+
+  // Unsigned chars could conceivably be handled consistently with signed chars but on arm64 these
+  // end up being stored as signed shorts.
+  FSTFieldValue *wrapped = FSTTestFieldValue([NSNumber numberWithUnsignedChar:1]);
+  XCTAssertEqualObjects(wrapped, [FSTIntegerValue integerValue:1]);
+}
+
+union DoubleBits {
+  double d;
+  uint64_t bits;
+};
+
+- (void)testNormalizesNaNs {
+  // NOTE: With v1beta1 query semantics, it's no longer as important that our NaN representation
+  // matches the backend, since all NaNs are defined to sort as equal, but we preserve the
+  // normalization and this test regardless for now.
+
+  // We use a canonical NaN bit pattern that's common for both Java and Objective-C. Specifically:
+  //   - sign: 0
+  //   - exponent: 11 bits, all 1
+  //   - significand: 52 bits, MSB=1, rest=0
+  //
+  // This matches the Firestore backend which uses Java's Double.doubleToLongBits which is defined
+  // to normalize all NaNs to this value.
+  union DoubleBits canonical = {.bits = 0x7ff8000000000000ULL};
+
+  // IEEE 754 specifies that NaN isn't equal to itself.
+  XCTAssertTrue(isnan(canonical.d));
+  XCTAssertEqual(canonical.bits, canonical.bits);
+  XCTAssertNotEqual(canonical.d, canonical.d);
+
+  // All permutations of the 51 other non-MSB significand bits are also NaNs.
+  union DoubleBits alternate = {.bits = 0x7fff000000000000ULL};
+  XCTAssertTrue(isnan(alternate.d));
+  XCTAssertNotEqual(alternate.bits, canonical.bits);
+  XCTAssertNotEqual(alternate.d, canonical.d);
+
+  // Even though at the C-level assignment preserves non-canonical NaNs, NSNumber normalizes all
+  // NaNs to single shared instance, kCFNumberNaN. That NaN has no public definition for its value
+  // but it happens to match what we need.
+  union DoubleBits normalized = {.d = [[NSNumber numberWithDouble:alternate.d] doubleValue]};
+  XCTAssertEqual(normalized.bits, canonical.bits);
+
+  // Ensure we get the same normalization behavior (currently implemented explicitly by checking
+  // for isnan() and then explicitly assigning NAN).
+  union DoubleBits result;
+  result.d = [[FSTDoubleValue doubleValue:canonical.d] internalValue];
+  XCTAssertEqual(result.bits, canonical.bits);
+
+  result.d = [[FSTDoubleValue doubleValue:alternate.d] internalValue];
+  XCTAssertEqual(result.bits, canonical.bits);
+
+  // A NaN that's canonical except it has the sign bit set (would be negative if signs mattered)
+  union DoubleBits negative = {.bits = 0xfff8000000000000ULL};
+  result.d = [[FSTDoubleValue doubleValue:negative.d] internalValue];
+  XCTAssertTrue(isnan(negative.d));
+  XCTAssertEqual(result.bits, canonical.bits);
+
+  // A signaling NaN with significand where MSB is 0, and some non-MSB bit is one.
+  union DoubleBits signaling = {.bits = 0xfff4000000000000ULL};
+  XCTAssertTrue(isnan(signaling.d));
+  result.d = [[FSTDoubleValue doubleValue:signaling.d] internalValue];
+  XCTAssertEqual(result.bits, canonical.bits);
+}
+
+- (void)testZeros {
+  // Floating point numbers have an explicit sign bit so it's possible to end up with negative
+  // zero as a distinct value from positive zero.
+  union DoubleBits zero = {.d = 0.0};
+  union DoubleBits negativeZero = {.d = -0.0};
+
+  // IEEE 754 requires these two zeros to compare equal.
+  XCTAssertNotEqual(zero.bits, negativeZero.bits);
+  XCTAssertEqual(zero.d, negativeZero.d);
+
+  // NSNumber preserves the negative zero value but compares equal according to IEEE 754.
+  union DoubleBits normalized = {.d = [[NSNumber numberWithDouble:negativeZero.d] doubleValue]};
+  XCTAssertEqual(normalized.bits, negativeZero.bits);
+  XCTAssertEqualObjects([NSNumber numberWithDouble:0.0], [NSNumber numberWithDouble:-0.0]);
+
+  // FSTDoubleValue preserves positive/negative zero
+  union DoubleBits result;
+  result.d = [[[FSTDoubleValue doubleValue:zero.d] value] doubleValue];
+  XCTAssertEqual(result.bits, zero.bits);
+  result.d = [[[FSTDoubleValue doubleValue:negativeZero.d] value] doubleValue];
+  XCTAssertEqual(result.bits, negativeZero.bits);
+
+  // ... but compares positive/negative zero as unequal, compatibly with Firestore.
+  XCTAssertNotEqualObjects([FSTDoubleValue doubleValue:0.0], [FSTDoubleValue doubleValue:-0.0]);
+}
+
+- (void)testWrapStrings {
+  NSArray *values = @[ @"", @"abc" ];
+  for (id value in values) {
+    FSTFieldValue *wrapped = FSTTestFieldValue(value);
+    XCTAssertEqualObjects([wrapped class], [FSTStringValue class]);
+    XCTAssertEqualObjects([wrapped value], value);
+  }
+}
+
+- (void)testWrapDates {
+  NSArray *values = @[ FSTTestDate(1900, 12, 1, 1, 20, 30), FSTTestDate(2017, 4, 24, 13, 20, 30) ];
+  for (id value in values) {
+    FSTFieldValue *wrapped = FSTTestFieldValue(value);
+    XCTAssertEqualObjects([wrapped class], [FSTTimestampValue class]);
+    XCTAssertEqualObjects([wrapped value], value);
+
+    XCTAssertEqualObjects(((FSTTimestampValue *)wrapped).internalValue,
+                          [FSTTimestamp timestampWithDate:value]);
+  }
+}
+
+- (void)testWrapGeoPoints {
+  NSArray *values = @[ FSTTestGeoPoint(1.24, 4.56), FSTTestGeoPoint(-20, 100) ];
+
+  for (id value in values) {
+    FSTFieldValue *wrapped = FSTTestFieldValue(value);
+    XCTAssertEqualObjects([wrapped class], [FSTGeoPointValue class]);
+    XCTAssertEqualObjects([wrapped value], value);
+  }
+}
+
+- (void)testWrapBlobs {
+  NSArray *values = @[ FSTTestData(1, 2, 3), FSTTestData(1, 2) ];
+  for (id value in values) {
+    FSTFieldValue *wrapped = FSTTestFieldValue(value);
+    XCTAssertEqualObjects([wrapped class], [FSTBlobValue class]);
+    XCTAssertEqualObjects([wrapped value], value);
+  }
+}
+
+- (void)testWrapResourceNames {
+  NSArray *values = @[
+    FSTTestRef(@"project", kDefaultDatabaseID, @"foo/bar"),
+    FSTTestRef(@"project", kDefaultDatabaseID, @"foo/baz")
+  ];
+  for (FSTDocumentKeyReference *value in values) {
+    FSTFieldValue *wrapped = FSTTestFieldValue(value);
+    XCTAssertEqualObjects([wrapped class], [FSTReferenceValue class]);
+    XCTAssertEqualObjects([wrapped value], value.key);
+    XCTAssertEqualObjects(((FSTDatabaseID *)wrapped).databaseID, value.databaseID);
+  }
+}
+
+- (void)testWrapsEmptyObjects {
+  XCTAssertEqualObjects(FSTTestFieldValue(@{}), [FSTObjectValue objectValue]);
+}
+
+- (void)testWrapsSimpleObjects {
+  FSTObjectValue *actual = FSTTestObjectValue(
+      @{ @"a" : @"foo",
+         @"b" : @(1L),
+         @"c" : @YES,
+         @"d" : [NSNull null] });
+  FSTObjectValue *expected = [[FSTObjectValue alloc] initWithDictionary:@{
+    @"a" : [FSTStringValue stringValue:@"foo"],
+    @"b" : [FSTIntegerValue integerValue:1LL],
+    @"c" : [FSTBooleanValue trueValue],
+    @"d" : [FSTNullValue nullValue]
+  }];
+  XCTAssertEqualObjects(actual, expected);
+}
+
+- (void)testWrapsNestedObjects {
+  FSTObjectValue *actual = FSTTestObjectValue(@{ @"a" : @{@"b" : @{@"c" : @"foo"}, @"d" : @YES} });
+  FSTObjectValue *expected = [[FSTObjectValue alloc] initWithDictionary:@{
+    @"a" : [[FSTObjectValue alloc] initWithDictionary:@{
+      @"b" :
+          [[FSTObjectValue alloc] initWithDictionary:@{@"c" : [FSTStringValue stringValue:@"foo"]}],
+      @"d" : [FSTBooleanValue booleanValue:YES]
+    }]
+  }];
+  XCTAssertEqualObjects(actual, expected);
+}
+
+- (void)testExtractsFields {
+  FSTObjectValue *obj = FSTTestObjectValue(@{ @"foo" : @{@"a" : @YES, @"b" : @"string"} });
+  FSTAssertIsKindOfClass(obj, FSTObjectValue);
+
+  FSTAssertIsKindOfClass([obj valueForPath:FSTTestFieldPath(@"foo")], FSTObjectValue);
+  XCTAssertEqualObjects([obj valueForPath:FSTTestFieldPath(@"foo.a")], [FSTBooleanValue trueValue]);
+  XCTAssertEqualObjects([obj valueForPath:FSTTestFieldPath(@"foo.b")],
+                        [FSTStringValue stringValue:@"string"]);
+
+  XCTAssertNil([obj valueForPath:FSTTestFieldPath(@"foo.a.b")]);
+  XCTAssertNil([obj valueForPath:FSTTestFieldPath(@"bar")]);
+  XCTAssertNil([obj valueForPath:FSTTestFieldPath(@"bar.a")]);
+}
+
+- (void)testOverwritesExistingFields {
+  FSTObjectValue *old = FSTTestObjectValue(@{@"a" : @"old"});
+  FSTObjectValue *mod =
+      [old objectBySettingValue:FSTTestFieldValue(@"mod") forPath:FSTTestFieldPath(@"a")];
+
+  // Should return a new object, leaving the old one unmodified.
+  XCTAssertNotEqual(old, mod);
+  XCTAssertEqualObjects(old, FSTTestFieldValue(@{@"a" : @"old"}));
+  XCTAssertEqualObjects(mod, FSTTestFieldValue(@{@"a" : @"mod"}));
+}
+
+- (void)testAddsNewFields {
+  FSTObjectValue *empty = [FSTObjectValue objectValue];
+  FSTObjectValue *mod =
+      [empty objectBySettingValue:FSTTestFieldValue(@"mod") forPath:FSTTestFieldPath(@"a")];
+  XCTAssertNotEqual(empty, mod);
+  XCTAssertEqualObjects(empty, FSTTestFieldValue(@{}));
+  XCTAssertEqualObjects(mod, FSTTestFieldValue(@{@"a" : @"mod"}));
+
+  FSTObjectValue *old = mod;
+  mod = [old objectBySettingValue:FSTTestFieldValue(@1) forPath:FSTTestFieldPath(@"b")];
+  XCTAssertNotEqual(old, mod);
+  XCTAssertEqualObjects(old, FSTTestFieldValue(@{@"a" : @"mod"}));
+  XCTAssertEqualObjects(mod, FSTTestFieldValue(@{ @"a" : @"mod", @"b" : @1 }));
+}
+
+- (void)testImplicitlyCreatesObjects {
+  FSTObjectValue *old = FSTTestObjectValue(@{@"a" : @"old"});
+  FSTObjectValue *mod =
+      [old objectBySettingValue:FSTTestFieldValue(@"mod") forPath:FSTTestFieldPath(@"b.c.d")];
+  XCTAssertNotEqual(old, mod);
+  XCTAssertEqualObjects(old, FSTTestFieldValue(@{@"a" : @"old"}));
+  XCTAssertEqualObjects(mod, FSTTestFieldValue(
+                                 @{ @"a" : @"old",
+                                    @"b" : @{@"c" : @{@"d" : @"mod"}} }));
+}
+
+- (void)testCanOverwritePrimitivesWithObjects {
+  FSTObjectValue *old = FSTTestObjectValue(@{ @"a" : @{@"b" : @"old"} });
+  FSTObjectValue *mod =
+      [old objectBySettingValue:FSTTestFieldValue(@{@"b" : @"mod"}) forPath:FSTTestFieldPath(@"a")];
+  XCTAssertNotEqual(old, mod);
+  XCTAssertEqualObjects(old, FSTTestFieldValue(@{ @"a" : @{@"b" : @"old"} }));
+  XCTAssertEqualObjects(mod, FSTTestFieldValue(@{ @"a" : @{@"b" : @"mod"} }));
+}
+
+- (void)testAddsToNestedObjects {
+  FSTObjectValue *old = FSTTestObjectValue(@{ @"a" : @{@"b" : @"old"} });
+  FSTObjectValue *mod =
+      [old objectBySettingValue:FSTTestFieldValue(@"mod") forPath:FSTTestFieldPath(@"a.c")];
+  XCTAssertNotEqual(old, mod);
+  XCTAssertEqualObjects(old, FSTTestFieldValue(@{ @"a" : @{@"b" : @"old"} }));
+  XCTAssertEqualObjects(mod, FSTTestFieldValue(@{ @"a" : @{@"b" : @"old", @"c" : @"mod"} }));
+}
+
+- (void)testDeletesKeys {
+  FSTObjectValue *old = FSTTestObjectValue(@{ @"a" : @1, @"b" : @2 });
+  FSTObjectValue *mod = [old objectByDeletingPath:FSTTestFieldPath(@"a")];
+  XCTAssertNotEqual(old, mod);
+  XCTAssertEqualObjects(old, FSTTestFieldValue(@{ @"a" : @1, @"b" : @2 }));
+  XCTAssertEqualObjects(mod, FSTTestFieldValue(@{ @"b" : @2 }));
+
+  FSTObjectValue *empty = [mod objectByDeletingPath:FSTTestFieldPath(@"b")];
+  XCTAssertNotEqual(mod, empty);
+  XCTAssertEqualObjects(mod, FSTTestFieldValue(@{ @"b" : @2 }));
+  XCTAssertEqualObjects(empty, FSTTestFieldValue(@{}));
+}
+
+- (void)testDeletesHandleMissingKeys {
+  FSTObjectValue *old = FSTTestObjectValue(@{ @"a" : @{@"b" : @1, @"c" : @2} });
+  FSTObjectValue *mod = [old objectByDeletingPath:FSTTestFieldPath(@"b")];
+  XCTAssertEqualObjects(old, mod);
+  XCTAssertEqualObjects(mod, FSTTestFieldValue(@{ @"a" : @{@"b" : @1, @"c" : @2} }));
+
+  mod = [old objectByDeletingPath:FSTTestFieldPath(@"a.d")];
+  XCTAssertEqualObjects(old, mod);
+  XCTAssertEqualObjects(mod, FSTTestFieldValue(@{ @"a" : @{@"b" : @1, @"c" : @2} }));
+
+  mod = [old objectByDeletingPath:FSTTestFieldPath(@"a.b.c")];
+  XCTAssertEqualObjects(old, mod);
+  XCTAssertEqualObjects(mod, FSTTestFieldValue(@{ @"a" : @{@"b" : @1, @"c" : @2} }));
+}
+
+- (void)testDeletesNestedKeys {
+  FSTObjectValue *old = FSTTestObjectValue(
+      @{ @"a" : @{@"b" : @1, @"c" : @{@"d" : @2, @"e" : @3}} });
+  FSTObjectValue *mod = [old objectByDeletingPath:FSTTestFieldPath(@"a.c.d")];
+  XCTAssertNotEqual(old, mod);
+  XCTAssertEqualObjects(old, FSTTestFieldValue(
+                                 @{ @"a" : @{@"b" : @1, @"c" : @{@"d" : @2, @"e" : @3}} }));
+  XCTAssertEqualObjects(mod, FSTTestFieldValue(@{ @"a" : @{@"b" : @1, @"c" : @{@"e" : @3}} }));
+
+  old = mod;
+  mod = [old objectByDeletingPath:FSTTestFieldPath(@"a.c")];
+  XCTAssertEqualObjects(old, FSTTestFieldValue(@{ @"a" : @{@"b" : @1, @"c" : @{@"e" : @3}} }));
+  XCTAssertEqualObjects(mod, FSTTestFieldValue(@{ @"a" : @{@"b" : @1} }));
+
+  old = mod;
+  mod = [old objectByDeletingPath:FSTTestFieldPath(@"a")];
+  XCTAssertEqualObjects(old, FSTTestFieldValue(@{ @"a" : @{@"b" : @1} }));
+  XCTAssertEqualObjects(mod, FSTTestFieldValue(@{}));
+}
+
+- (void)testArrays {
+  FSTArrayValue *expected = [[FSTArrayValue alloc]
+      initWithValueNoCopy:@[ [FSTStringValue stringValue:@"value"], [FSTBooleanValue trueValue] ]];
+
+  FSTArrayValue *actual = (FSTArrayValue *)FSTTestFieldValue(@[ @"value", @YES ]);
+  XCTAssertEqualObjects(actual, expected);
+}
+
+- (void)testValueEquality {
+  NSArray *groups = @[
+    @[ FSTTestFieldValue(@YES), [FSTBooleanValue booleanValue:YES] ],
+    @[ FSTTestFieldValue(@NO), [FSTBooleanValue booleanValue:NO] ],
+    @[ FSTTestFieldValue([NSNull null]), [FSTNullValue nullValue] ],
+    @[ FSTTestFieldValue(@(0.0 / 0.0)), FSTTestFieldValue(@(NAN)), [FSTDoubleValue nanValue] ],
+    // -0.0 and 0.0 compare: the same (but are not isEqual:)
+    @[ FSTTestFieldValue(@(-0.0)) ], @[ FSTTestFieldValue(@0.0) ],
+    @[ FSTTestFieldValue(@1), FSTTestFieldValue(@1LL), [FSTIntegerValue integerValue:1LL] ],
+    // double and unit64_t values can compare: the same (but won't be isEqual:)
+    @[ FSTTestFieldValue(@1.0), [FSTDoubleValue doubleValue:1.0] ],
+    @[ FSTTestFieldValue(@1.1), [FSTDoubleValue doubleValue:1.1] ],
+    @[
+      FSTTestFieldValue(FSTTestData(0, 1, 2, -1)), [FSTBlobValue blobValue:FSTTestData(0, 1, 2, -1)]
+    ],
+    @[ FSTTestFieldValue(FSTTestData(0, 1, -1)) ],
+    @[ FSTTestFieldValue(@"string"), [FSTStringValue stringValue:@"string"] ],
+    @[ FSTTestFieldValue(@"strin") ],
+    @[ FSTTestFieldValue(@"e\u0301b") ],  // latin small letter e + combining acute accent
+    @[ FSTTestFieldValue(@"\u00e9a") ],   // latin small letter e with acute accent
+    @[
+      FSTTestFieldValue(date1),
+      [FSTTimestampValue timestampValue:[FSTTimestamp timestampWithDate:date1]]
+    ],
+    @[ FSTTestFieldValue(date2) ],
+    @[
+      // NOTE: ServerTimestampValues can't be parsed via FSTTestFieldValue().
+      [FSTServerTimestampValue
+          serverTimestampValueWithLocalWriteTime:[FSTTimestamp timestampWithDate:date1]],
+      [FSTServerTimestampValue
+          serverTimestampValueWithLocalWriteTime:[FSTTimestamp timestampWithDate:date1]]
+    ],
+    @[ [FSTServerTimestampValue
+        serverTimestampValueWithLocalWriteTime:[FSTTimestamp timestampWithDate:date2]] ],
+    @[
+      FSTTestFieldValue(FSTTestGeoPoint(0, 1)),
+      [FSTGeoPointValue geoPointValue:FSTTestGeoPoint(0, 1)]
+    ],
+    @[ FSTTestFieldValue(FSTTestGeoPoint(1, 0)) ],
+    @[
+      [FSTReferenceValue referenceValue:FSTTestDocKey(@"coll/doc1")
+                             databaseID:[FSTDatabaseID databaseIDWithProject:@"project"
+                                                                    database:kDefaultDatabaseID]],
+      FSTTestFieldValue(FSTTestRef(@"project", kDefaultDatabaseID, @"coll/doc1"))
+    ],
+    @[ FSTTestRef(@"project", @"(default)", @"coll/doc2") ],
+    @[ FSTTestFieldValue(@[ @"foo", @"bar" ]), FSTTestFieldValue(@[ @"foo", @"bar" ]) ],
+    @[ FSTTestFieldValue(@[ @"foo", @"bar", @"baz" ]) ], @[ FSTTestFieldValue(@[ @"foo" ]) ],
+    @[
+      FSTTestFieldValue(
+          @{ @"bar" : @1,
+             @"foo" : @2 }),
+      FSTTestFieldValue(
+          @{ @"foo" : @2,
+             @"bar" : @1 })
+    ],
+    @[ FSTTestFieldValue(
+        @{ @"bar" : @2,
+           @"foo" : @1 }) ],
+    @[ FSTTestFieldValue(
+        @{ @"bar" : @1,
+           @"foo" : @1 }) ],
+    @[ FSTTestFieldValue(
+        @{ @"foo" : @1 }) ]
+  ];
+
+  FSTAssertEqualityGroups(groups);
+}
+
+- (void)testValueOrdering {
+  NSArray *groups = @[
+    // null first
+    @[ [NSNull null] ],
+
+    // booleans
+    @[ @NO ], @[ @YES ],
+
+    // numbers
+    @[ @(0.0 / 0.0) ], @[ @(-INFINITY) ], @[ @(-DBL_MAX) ], @[ @(LLONG_MIN) ], @[ @(-1.1) ],
+    @[ @(-1.0), @(-1LL) ],  // longs and doubles compare the same
+    @[ @(-DBL_MIN) ],
+    @[ @(-0x1.0p-1074) ],              // negative smallest subnormal
+    @[ @(-0.0), @(0.0), @(0LL) ],      // zeros all compare the same
+    @[ @(0x1.0p-1074) ],               // positive smallest subnormal
+    @[ @(DBL_MIN) ], @[ @1.0, @1LL ],  // longs and doubles compare the same
+    @[ @1.1 ], @[ @(LLONG_MAX) ], @[ @(DBL_MAX) ], @[ @(INFINITY) ],
+
+    // timestamps
+    @[ date1 ], @[ date2 ],
+
+    // server timestamps come after all concrete timestamps.
+    // NOTE: server timestamps can't be parsed directly, so we have special sentinel strings (see
+    // FSTWrapGroups()).
+    @[ @"server-timestamp-1" ], @[ @"server-timestamp-2" ],
+
+    // strings
+    @[ @"" ], @[ @"\000\ud7ff\ue000\uffff" ], @[ @"(╯°□°)╯︵ ┻━┻" ], @[ @"a" ], @[ @"abc def" ],
+    @[ @"e\u0301b" ],  // latin small letter e + combining acute accent + latin small letter b
+    @[ @"æ" ],
+    @[ @"\u00e9a" ],  // latin small letter e with acute accent + latin small letter a
+
+    // blobs
+    @[ FSTTestData(-1) ], @[ FSTTestData(0, -1) ], @[ FSTTestData(0, 1, 2, 3, 4, -1) ],
+    @[ FSTTestData(0, 1, 2, 4, 3, -1) ], @[ FSTTestData(255, -1) ],
+
+    // resource names
+    @[ FSTTestRef(@"p1", @"d1", @"c1/doc1") ], @[ FSTTestRef(@"p1", @"d1", @"c1/doc2") ],
+    @[ FSTTestRef(@"p1", @"d1", @"c10/doc1") ], @[ FSTTestRef(@"p1", @"d1", @"c2/doc1") ],
+    @[ FSTTestRef(@"p1", @"d2", @"c1/doc1") ], @[ FSTTestRef(@"p2", @"d1", @"c1/doc1") ],
+
+    // Geo points
+    @[ FSTTestGeoPoint(-90, -180) ], @[ FSTTestGeoPoint(-90, 0) ], @[ FSTTestGeoPoint(-90, 180) ],
+    @[ FSTTestGeoPoint(0, -180) ], @[ FSTTestGeoPoint(0, 0) ], @[ FSTTestGeoPoint(0, 180) ],
+    @[ FSTTestGeoPoint(1, -180) ], @[ FSTTestGeoPoint(1, 0) ], @[ FSTTestGeoPoint(1, 180) ],
+    @[ FSTTestGeoPoint(90, -180) ], @[ FSTTestGeoPoint(90, 0) ], @[ FSTTestGeoPoint(90, 180) ],
+
+    // Arrays
+    @[ @[] ], @[ @[ @"bar" ] ], @[ @[ @"foo" ] ], @[ @[ @"foo", @1 ] ], @[ @[ @"foo", @2 ] ],
+    @[ @[ @"foo", @"0" ] ],
+
+    // Objects
+    @[
+      @{ @"bar" : @0 }
+    ],
+    @[
+      @{ @"bar" : @0,
+         @"foo" : @1 }
+    ],
+    @[
+      @{ @"foo" : @1 }
+    ],
+    @[
+      @{ @"foo" : @2 }
+    ],
+    @[ @{@"foo" : @"0"} ]
+  ];
+
+  NSArray *wrapped = FSTWrapGroups(groups);
+  FSTAssertComparisons(wrapped);
+}
+
+- (void)testValue {
+  NSDate *date = [NSDate date];
+  id input = @{ @"array" : @[ @1, date ], @"obj" : @{@"date" : date, @"string" : @"hi"} };
+  FSTObjectValue *value = FSTTestObjectValue(input);
+  id output = [value value];
+  {
+    XCTAssertTrue([output[@"array"][1] isKindOfClass:[NSDate class]]);
+    NSDate *actual = output[@"array"][1];
+    XCTAssertEqualWithAccuracy(date.timeIntervalSince1970, actual.timeIntervalSince1970,
+                               0.000000001);
+  }
+  {
+    XCTAssertTrue([output[@"obj"][@"date"] isKindOfClass:[NSDate class]]);
+    NSDate *actual = output[@"obj"][@"date"];
+    XCTAssertEqualWithAccuracy(date.timeIntervalSince1970, actual.timeIntervalSince1970,
+                               0.000000001);
+  }
+}
+
+@end

+ 216 - 0
Firestore/Example/Tests/Model/FSTMutationTests.m

@@ -0,0 +1,216 @@
+/*
+ * 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 "Model/FSTMutation.h"
+
+#import <XCTest/XCTest.h>
+
+#import "Core/FSTTimestamp.h"
+#import "Model/FSTDocument.h"
+#import "Model/FSTDocumentKey.h"
+#import "Model/FSTFieldValue.h"
+#import "Model/FSTPath.h"
+
+#import "FSTHelpers.h"
+
+@interface FSTMutationTests : XCTestCase
+@end
+
+@implementation FSTMutationTests {
+  FSTTimestamp *_timestamp;
+}
+
+- (void)setUp {
+  _timestamp = [FSTTimestamp timestamp];
+}
+
+- (void)testAppliesSetsToDocuments {
+  NSDictionary *docData = @{@"foo" : @"foo-value", @"baz" : @"baz-value"};
+  FSTDocument *baseDoc = FSTTestDoc(@"collection/key", 0, docData, NO);
+
+  FSTMutation *set = FSTTestSetMutation(@"collection/key", @{@"bar" : @"bar-value"});
+  FSTMaybeDocument *setDoc = [set applyTo:baseDoc localWriteTime:_timestamp];
+
+  NSDictionary *expectedData = @{@"bar" : @"bar-value"};
+  XCTAssertEqualObjects(setDoc, FSTTestDoc(@"collection/key", 0, expectedData, YES));
+}
+
+- (void)testAppliesPatchesToDocuments {
+  NSDictionary *docData = @{ @"foo" : @{@"bar" : @"bar-value"}, @"baz" : @"baz-value" };
+  FSTDocument *baseDoc = FSTTestDoc(@"collection/key", 0, docData, NO);
+
+  FSTMutation *patch =
+      FSTTestPatchMutation(@"collection/key", @{@"foo.bar" : @"new-bar-value"}, nil);
+  FSTMaybeDocument *patchedDoc = [patch applyTo:baseDoc localWriteTime:_timestamp];
+
+  NSDictionary *expectedData = @{ @"foo" : @{@"bar" : @"new-bar-value"}, @"baz" : @"baz-value" };
+  XCTAssertEqualObjects(patchedDoc, FSTTestDoc(@"collection/key", 0, expectedData, YES));
+}
+
+- (void)testDeletesValuesFromTheFieldMask {
+  NSDictionary *docData = @{ @"foo" : @{@"bar" : @"bar-value", @"baz" : @"baz-value"} };
+  FSTDocument *baseDoc = FSTTestDoc(@"collection/key", 0, docData, NO);
+
+  FSTDocumentKey *key = [FSTDocumentKey keyWithPathString:@"collection/key"];
+  FSTFieldMask *mask = [[FSTFieldMask alloc] initWithFields:@[ FSTTestFieldPath(@"foo.bar") ]];
+  FSTMutation *patch = [[FSTPatchMutation alloc] initWithKey:key
+                                                   fieldMask:mask
+                                                       value:[FSTObjectValue objectValue]
+                                                precondition:[FSTPrecondition none]];
+  FSTMaybeDocument *patchedDoc = [patch applyTo:baseDoc localWriteTime:_timestamp];
+
+  NSDictionary *expectedData = @{ @"foo" : @{@"baz" : @"baz-value"} };
+  XCTAssertEqualObjects(patchedDoc, FSTTestDoc(@"collection/key", 0, expectedData, YES));
+}
+
+- (void)testPatchesPrimitiveValue {
+  NSDictionary *docData = @{@"foo" : @"foo-value", @"baz" : @"baz-value"};
+  FSTDocument *baseDoc = FSTTestDoc(@"collection/key", 0, docData, NO);
+
+  FSTMutation *patch =
+      FSTTestPatchMutation(@"collection/key", @{@"foo.bar" : @"new-bar-value"}, nil);
+  FSTMaybeDocument *patchedDoc = [patch applyTo:baseDoc localWriteTime:_timestamp];
+
+  NSDictionary *expectedData = @{ @"foo" : @{@"bar" : @"new-bar-value"}, @"baz" : @"baz-value" };
+  XCTAssertEqualObjects(patchedDoc, FSTTestDoc(@"collection/key", 0, expectedData, YES));
+}
+
+- (void)testPatchingDeletedDocumentsDoesNothing {
+  FSTMaybeDocument *baseDoc = FSTTestDeletedDoc(@"collection/key", 0);
+  FSTMutation *patch = FSTTestPatchMutation(@"collection/key", @{@"foo" : @"bar"}, nil);
+  FSTMaybeDocument *patchedDoc = [patch applyTo:baseDoc localWriteTime:_timestamp];
+  XCTAssertEqualObjects(patchedDoc, baseDoc);
+}
+
+- (void)testAppliesLocalTransformsToDocuments {
+  NSDictionary *docData = @{ @"foo" : @{@"bar" : @"bar-value"}, @"baz" : @"baz-value" };
+  FSTDocument *baseDoc = FSTTestDoc(@"collection/key", 0, docData, NO);
+
+  FSTMutation *transform = FSTTestTransformMutation(@"collection/key", @[ @"foo.bar" ]);
+  FSTMaybeDocument *transformedDoc = [transform applyTo:baseDoc localWriteTime:_timestamp];
+
+  // Server timestamps aren't parsed, so we manually insert it.
+  FSTObjectValue *expectedData = FSTTestObjectValue(
+      @{ @"foo" : @{@"bar" : @"<server-timestamp>"},
+         @"baz" : @"baz-value" });
+  expectedData =
+      [expectedData objectBySettingValue:[FSTServerTimestampValue
+                                             serverTimestampValueWithLocalWriteTime:_timestamp]
+                                 forPath:FSTTestFieldPath(@"foo.bar")];
+
+  FSTDocument *expectedDoc = [FSTDocument documentWithData:expectedData
+                                                       key:FSTTestDocKey(@"collection/key")
+                                                   version:FSTTestVersion(0)
+                                         hasLocalMutations:YES];
+
+  XCTAssertEqualObjects(transformedDoc, expectedDoc);
+}
+
+- (void)testAppliesServerAckedTransformsToDocuments {
+  NSDictionary *docData = @{ @"foo" : @{@"bar" : @"bar-value"}, @"baz" : @"baz-value" };
+  FSTDocument *baseDoc = FSTTestDoc(@"collection/key", 0, docData, NO);
+
+  FSTMutation *transform = FSTTestTransformMutation(@"collection/key", @[ @"foo.bar" ]);
+
+  FSTMutationResult *mutationResult = [[FSTMutationResult alloc]
+       initWithVersion:FSTTestVersion(1)
+      transformResults:@[ [FSTTimestampValue timestampValue:_timestamp] ]];
+
+  FSTMaybeDocument *transformedDoc =
+      [transform applyTo:baseDoc localWriteTime:_timestamp mutationResult:mutationResult];
+
+  NSDictionary *expectedData =
+      @{ @"foo" : @{@"bar" : _timestamp.approximateDateValue},
+         @"baz" : @"baz-value" };
+  XCTAssertEqualObjects(transformedDoc, FSTTestDoc(@"collection/key", 0, expectedData, NO));
+}
+
+- (void)testDeleteDeletes {
+  NSDictionary *docData = @{@"foo" : @"bar"};
+  FSTDocument *baseDoc = FSTTestDoc(@"collection/key", 0, docData, NO);
+
+  FSTMutation *mutation = FSTTestDeleteMutation(@"collection/key");
+  FSTMaybeDocument *result = [mutation applyTo:baseDoc localWriteTime:_timestamp];
+  XCTAssertEqualObjects(result, FSTTestDeletedDoc(@"collection/key", 0));
+}
+
+- (void)testSetWithMutationResult {
+  NSDictionary *docData = @{@"foo" : @"bar"};
+  FSTDocument *baseDoc = FSTTestDoc(@"collection/key", 0, docData, NO);
+
+  FSTMutation *set = FSTTestSetMutation(@"collection/key", @{@"foo" : @"new-bar"});
+  FSTMutationResult *mutationResult =
+      [[FSTMutationResult alloc] initWithVersion:FSTTestVersion(4) transformResults:nil];
+  FSTMaybeDocument *setDoc =
+      [set applyTo:baseDoc localWriteTime:_timestamp mutationResult:mutationResult];
+
+  NSDictionary *expectedData = @{@"foo" : @"new-bar"};
+  XCTAssertEqualObjects(setDoc, FSTTestDoc(@"collection/key", 0, expectedData, NO));
+}
+
+- (void)testPatchWithMutationResult {
+  NSDictionary *docData = @{@"foo" : @"bar"};
+  FSTDocument *baseDoc = FSTTestDoc(@"collection/key", 0, docData, NO);
+
+  FSTMutation *patch = FSTTestPatchMutation(@"collection/key", @{@"foo" : @"new-bar"}, nil);
+  FSTMutationResult *mutationResult =
+      [[FSTMutationResult alloc] initWithVersion:FSTTestVersion(4) transformResults:nil];
+  FSTMaybeDocument *patchedDoc =
+      [patch applyTo:baseDoc localWriteTime:_timestamp mutationResult:mutationResult];
+
+  NSDictionary *expectedData = @{@"foo" : @"new-bar"};
+  XCTAssertEqualObjects(patchedDoc, FSTTestDoc(@"collection/key", 0, expectedData, NO));
+}
+
+#define ASSERT_VERSION_TRANSITION(mutation, base, expected)                                 \
+  do {                                                                                      \
+    FSTMutationResult *mutationResult =                                                     \
+        [[FSTMutationResult alloc] initWithVersion:FSTTestVersion(0) transformResults:nil]; \
+    FSTMaybeDocument *actual =                                                              \
+        [mutation applyTo:base localWriteTime:_timestamp mutationResult:mutationResult];    \
+    XCTAssertEqualObjects(actual, expected);                                                \
+  } while (0);
+
+/**
+ * Tests the transition table documented in FSTMutation.h.
+ */
+- (void)testTransitions {
+  FSTDocument *docV0 = FSTTestDoc(@"collection/key", 0, @{}, NO);
+  FSTDeletedDocument *deletedV0 = FSTTestDeletedDoc(@"collection/key", 0);
+
+  FSTDocument *docV3 = FSTTestDoc(@"collection/key", 3, @{}, NO);
+  FSTDeletedDocument *deletedV3 = FSTTestDeletedDoc(@"collection/key", 3);
+
+  FSTMutation *setMutation = FSTTestSetMutation(@"collection/key", @{});
+  FSTMutation *patchMutation = FSTTestPatchMutation(@"collection/key", @{}, nil);
+  FSTMutation *deleteMutation = FSTTestDeleteMutation(@"collection/key");
+
+  ASSERT_VERSION_TRANSITION(setMutation, docV3, docV3);
+  ASSERT_VERSION_TRANSITION(setMutation, deletedV3, docV0);
+  ASSERT_VERSION_TRANSITION(setMutation, nil, docV0);
+
+  ASSERT_VERSION_TRANSITION(patchMutation, docV3, docV3);
+  ASSERT_VERSION_TRANSITION(patchMutation, deletedV3, deletedV3);
+  ASSERT_VERSION_TRANSITION(patchMutation, nil, nil);
+
+  ASSERT_VERSION_TRANSITION(deleteMutation, docV3, deletedV0);
+  ASSERT_VERSION_TRANSITION(deleteMutation, deletedV3, deletedV0);
+  ASSERT_VERSION_TRANSITION(deleteMutation, nil, deletedV0);
+}
+
+#undef ASSERT_TRANSITION
+
+@end

+ 196 - 0
Firestore/Example/Tests/Model/FSTPathTests.m

@@ -0,0 +1,196 @@
+/*
+ * 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 "FSTHelpers.h"
+#import "Model/FSTPath.h"
+
+#import <XCTest/XCTest.h>
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTFieldPathTests : XCTestCase
+@end
+
+@implementation FSTFieldPathTests
+
+- (void)testConstructor {
+  FSTFieldPath *path = [FSTFieldPath pathWithSegments:@[ @"rooms", @"Eros", @"messages" ]];
+  XCTAssertEqual(3, path.length);
+}
+
+- (void)testIndexing {
+  FSTFieldPath *path = [FSTFieldPath pathWithSegments:@[ @"rooms", @"Eros", @"messages" ]];
+  XCTAssertEqualObjects(@"rooms", path.firstSegment);
+  XCTAssertEqualObjects(@"rooms", [path segmentAtIndex:0]);
+  XCTAssertEqualObjects(@"rooms", path[0]);
+
+  XCTAssertEqualObjects(@"Eros", [path segmentAtIndex:1]);
+  XCTAssertEqualObjects(@"Eros", path[1]);
+
+  XCTAssertEqualObjects(@"messages", [path segmentAtIndex:2]);
+  XCTAssertEqualObjects(@"messages", path[2]);
+  XCTAssertEqualObjects(@"messages", path.lastSegment);
+}
+
+- (void)testPathByRemovingFirstSegment {
+  FSTFieldPath *path = [FSTFieldPath pathWithSegments:@[ @"rooms", @"Eros", @"messages" ]];
+  FSTFieldPath *same = [FSTFieldPath pathWithSegments:@[ @"rooms", @"Eros", @"messages" ]];
+  FSTFieldPath *second = [FSTFieldPath pathWithSegments:@[ @"Eros", @"messages" ]];
+  FSTFieldPath *third = [FSTFieldPath pathWithSegments:@[ @"messages" ]];
+  FSTFieldPath *empty = [FSTFieldPath pathWithSegments:@[]];
+
+  XCTAssertEqualObjects(second, path.pathByRemovingFirstSegment);
+  XCTAssertEqualObjects(third, path.pathByRemovingFirstSegment.pathByRemovingFirstSegment);
+  XCTAssertEqualObjects(
+      empty, path.pathByRemovingFirstSegment.pathByRemovingFirstSegment.pathByRemovingFirstSegment);
+  // unmodified original
+  XCTAssertEqualObjects(same, path);
+}
+
+- (void)testPathByRemovingLastSegment {
+  FSTFieldPath *path = [FSTFieldPath pathWithSegments:@[ @"rooms", @"Eros", @"messages" ]];
+  FSTFieldPath *same = [FSTFieldPath pathWithSegments:@[ @"rooms", @"Eros", @"messages" ]];
+  FSTFieldPath *second = [FSTFieldPath pathWithSegments:@[ @"rooms", @"Eros" ]];
+  FSTFieldPath *third = [FSTFieldPath pathWithSegments:@[ @"rooms" ]];
+  FSTFieldPath *empty = [FSTFieldPath pathWithSegments:@[]];
+
+  XCTAssertEqualObjects(second, path.pathByRemovingLastSegment);
+  XCTAssertEqualObjects(third, path.pathByRemovingLastSegment.pathByRemovingLastSegment);
+  XCTAssertEqualObjects(
+      empty, path.pathByRemovingLastSegment.pathByRemovingLastSegment.pathByRemovingLastSegment);
+  // unmodified original
+  XCTAssertEqualObjects(same, path);
+}
+
+- (void)testPathByAppendingSegment {
+  FSTFieldPath *path = [FSTFieldPath pathWithSegments:@[ @"rooms" ]];
+  FSTFieldPath *rooms = [FSTFieldPath pathWithSegments:@[ @"rooms" ]];
+  FSTFieldPath *roomsEros = [FSTFieldPath pathWithSegments:@[ @"rooms", @"eros" ]];
+  FSTFieldPath *roomsEros1 = [FSTFieldPath pathWithSegments:@[ @"rooms", @"eros", @"1" ]];
+
+  XCTAssertEqualObjects(roomsEros, [path pathByAppendingSegment:@"eros"]);
+  XCTAssertEqualObjects(roomsEros1,
+                        [[path pathByAppendingSegment:@"eros"] pathByAppendingSegment:@"1"]);
+  // unmodified original
+  XCTAssertEqualObjects(rooms, path);
+
+  FSTFieldPath *sub = [FSTTestFieldPath(@"rooms.eros.1") pathByRemovingFirstSegment];
+  FSTFieldPath *appended = [sub pathByAppendingSegment:@"2"];
+  XCTAssertEqualObjects(appended, FSTTestFieldPath(@"eros.1.2"));
+}
+
+- (void)testPathComparison {
+  FSTFieldPath *path1 = [FSTFieldPath pathWithSegments:@[ @"a", @"b", @"c" ]];
+  FSTFieldPath *path2 = [FSTFieldPath pathWithSegments:@[ @"a", @"b", @"c" ]];
+  FSTFieldPath *path3 = [FSTFieldPath pathWithSegments:@[ @"x", @"y", @"z" ]];
+  XCTAssertTrue([path1 isEqual:path2]);
+  XCTAssertFalse([path1 isEqual:path3]);
+
+  FSTFieldPath *empty = [FSTFieldPath pathWithSegments:@[]];
+  FSTFieldPath *a = [FSTFieldPath pathWithSegments:@[ @"a" ]];
+  FSTFieldPath *b = [FSTFieldPath pathWithSegments:@[ @"b" ]];
+  FSTFieldPath *ab = [FSTFieldPath pathWithSegments:@[ @"a", @"b" ]];
+
+  XCTAssertEqual(NSOrderedAscending, [empty compare:a]);
+  XCTAssertEqual(NSOrderedAscending, [a compare:b]);
+  XCTAssertEqual(NSOrderedAscending, [a compare:ab]);
+
+  XCTAssertEqual(NSOrderedDescending, [a compare:empty]);
+  XCTAssertEqual(NSOrderedDescending, [b compare:a]);
+  XCTAssertEqual(NSOrderedDescending, [ab compare:a]);
+}
+
+- (void)testIsPrefixOfPath {
+  FSTFieldPath *empty = [FSTFieldPath pathWithSegments:@[]];
+  FSTFieldPath *a = [FSTFieldPath pathWithSegments:@[ @"a" ]];
+  FSTFieldPath *ab = [FSTFieldPath pathWithSegments:@[ @"a", @"b" ]];
+  FSTFieldPath *abc = [FSTFieldPath pathWithSegments:@[ @"a", @"b", @"c" ]];
+  FSTFieldPath *b = [FSTFieldPath pathWithSegments:@[ @"b" ]];
+  FSTFieldPath *ba = [FSTFieldPath pathWithSegments:@[ @"b", @"a" ]];
+
+  XCTAssertTrue([empty isPrefixOfPath:a]);
+  XCTAssertTrue([empty isPrefixOfPath:ab]);
+  XCTAssertTrue([empty isPrefixOfPath:abc]);
+  XCTAssertTrue([empty isPrefixOfPath:empty]);
+  XCTAssertTrue([empty isPrefixOfPath:b]);
+  XCTAssertTrue([empty isPrefixOfPath:ba]);
+
+  XCTAssertTrue([a isPrefixOfPath:a]);
+  XCTAssertTrue([a isPrefixOfPath:ab]);
+  XCTAssertTrue([a isPrefixOfPath:abc]);
+  XCTAssertFalse([a isPrefixOfPath:empty]);
+  XCTAssertFalse([a isPrefixOfPath:b]);
+  XCTAssertFalse([a isPrefixOfPath:ba]);
+
+  XCTAssertFalse([ab isPrefixOfPath:a]);
+  XCTAssertTrue([ab isPrefixOfPath:ab]);
+  XCTAssertTrue([ab isPrefixOfPath:abc]);
+  XCTAssertFalse([ab isPrefixOfPath:empty]);
+  XCTAssertFalse([ab isPrefixOfPath:b]);
+  XCTAssertFalse([ab isPrefixOfPath:ba]);
+
+  XCTAssertFalse([abc isPrefixOfPath:a]);
+  XCTAssertFalse([abc isPrefixOfPath:ab]);
+  XCTAssertTrue([abc isPrefixOfPath:abc]);
+  XCTAssertFalse([abc isPrefixOfPath:empty]);
+  XCTAssertFalse([abc isPrefixOfPath:b]);
+  XCTAssertFalse([abc isPrefixOfPath:ba]);
+}
+
+- (void)testInvalidPaths {
+  XCTAssertThrows(FSTTestFieldPath(@""));
+  XCTAssertThrows(FSTTestFieldPath(@"."));
+  XCTAssertThrows(FSTTestFieldPath(@".foo"));
+  XCTAssertThrows(FSTTestFieldPath(@"foo."));
+  XCTAssertThrows(FSTTestFieldPath(@"foo..bar"));
+}
+
+#define ASSERT_ROUND_TRIP(str, segments)                          \
+  do {                                                            \
+    FSTFieldPath *path = [FSTFieldPath pathWithServerFormat:str]; \
+    XCTAssertEqual([path length], segments);                      \
+    NSString *canonical = [path canonicalString];                 \
+    XCTAssertEqualObjects(canonical, str);                        \
+  } while (0);
+
+- (void)testCanonicalString {
+  ASSERT_ROUND_TRIP(@"foo", 1);
+  ASSERT_ROUND_TRIP(@"foo.bar", 2);
+  ASSERT_ROUND_TRIP(@"foo.bar.baz", 3);
+  ASSERT_ROUND_TRIP(@"`.foo\\\\`", 1);
+  ASSERT_ROUND_TRIP(@"`.foo\\\\`.`.foo`", 2);
+  ASSERT_ROUND_TRIP(@"foo.`\\``.bar", 3);
+}
+
+#undef ASSERT_ROUND_TRIP
+
+- (void)testCanonicalStringOfSubstring {
+  FSTFieldPath *path = [FSTFieldPath pathWithServerFormat:@"foo.bar.baz"];
+  XCTAssertEqualObjects([path canonicalString], @"foo.bar.baz");
+
+  FSTFieldPath *pathTail = [path pathByRemovingFirstSegment];
+  XCTAssertEqualObjects([pathTail canonicalString], @"bar.baz");
+
+  FSTFieldPath *pathHead = [path pathByRemovingLastSegment];
+  XCTAssertEqualObjects([pathHead canonicalString], @"foo.bar");
+
+  XCTAssertEqualObjects([[pathTail pathByRemovingLastSegment] canonicalString], @"bar");
+  XCTAssertEqualObjects([[pathHead pathByRemovingFirstSegment] canonicalString], @"bar");
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 58 - 0
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

+ 556 - 0
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

+ 794 - 0
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

+ 139 - 0
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

+ 40 - 0
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

+ 54 - 0
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

+ 66 - 0
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

+ 43 - 0
Firestore/Example/Tests/SpecTests/FSTLevelDBSpecTests.m

@@ -0,0 +1,43 @@
+/*
+ * 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 "FSTSpecTests.h"
+
+#import "Local/FSTLevelDB.h"
+
+#import "FSTPersistenceTestHelpers.h"
+#import "FSTSyncEngineTestDriver.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * An implementation of FSTSpecTests that uses the LevelDB implementation of local storage.
+ *
+ * See the FSTSpecTests class comments for more information about how this works.
+ */
+@interface FSTLevelDBSpecTests : FSTSpecTests
+@end
+
+@implementation FSTLevelDBSpecTests
+
+/** Overrides -[FSTSpecTests persistence] */
+- (id<FSTPersistence>)persistence {
+  return [FSTPersistenceTestHelpers levelDBPersistence];
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 42 - 0
Firestore/Example/Tests/SpecTests/FSTMemorySpecTests.m

@@ -0,0 +1,42 @@
+/*
+ * 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 "FSTSpecTests.h"
+
+#import "Local/FSTMemoryPersistence.h"
+
+#import "FSTPersistenceTestHelpers.h"
+#import "FSTSyncEngineTestDriver.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * An implementation of FSTSpecTests that uses the memory-only implementation of local storage.
+ *
+ * @see the FSTSpecTests class comments for more information about how this works.
+ */
+@interface FSTMemorySpecTests : FSTSpecTests
+@end
+
+@implementation FSTMemorySpecTests
+
+/** Overrides -[FSTSpecTests persistence] */
+- (id<FSTPersistence>)persistence {
+  return [FSTPersistenceTestHelpers memoryPersistence];
+}
+@end
+
+NS_ASSUME_NONNULL_END

+ 68 - 0
Firestore/Example/Tests/SpecTests/FSTMockDatastore.h

@@ -0,0 +1,68 @@
+/*
+ * 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 "Remote/FSTDatastore.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@interface FSTMockDatastore : FSTDatastore
+
++ (instancetype)mockDatastoreWithWorkerDispatchQueue:(FSTDispatchQueue *)workerDispatchQueue;
+
+#pragma mark - Watch Stream manipulation.
+
+/** Injects an Added WatchChange containing the given targetIDs. */
+- (void)writeWatchTargetAddedWithTargetIDs:(NSArray<FSTBoxedTargetID *> *)targetIDs;
+
+/** Injects an Added WatchChange that marks the given targetIDs current. */
+- (void)writeWatchCurrentWithTargetIDs:(NSArray<FSTBoxedTargetID *> *)targetIDs
+                       snapshotVersion:(FSTSnapshotVersion *)snapshotVersion
+                           resumeToken:(NSData *)resumeToken;
+
+/** Injects a WatchChange as though it had come from the backend. */
+- (void)writeWatchChange:(FSTWatchChange *)change snapshotVersion:(FSTSnapshotVersion *)snap;
+
+/** Injects a stream failure as though it had come from the backend. */
+- (void)failWatchStreamWithError:(NSError *)error;
+
+/** Returns the set of active targets on the watch stream. */
+- (NSDictionary<FSTBoxedTargetID *, FSTQueryData *> *)activeTargets;
+
+/** Helper method to expose watch stream state to verify in tests. */
+- (BOOL)isWatchStreamOpen;
+
+#pragma mark - Write Stream manipulation.
+
+/**
+ * Returns the next write that was "sent to the backend", failing if there are no queued sent
+ */
+- (NSArray<FSTMutation *> *)nextSentWrite;
+
+/** Returns the number of writes that have been sent to the backend but not waited on yet. */
+- (int)writesSent;
+
+/** Injects a write ack as though it had come from the backend in response to a write. */
+- (void)ackWriteWithVersion:(FSTSnapshotVersion *)commitVersion
+            mutationResults:(NSArray<FSTMutationResult *> *)results;
+
+/** Injects a stream failure as though it had come from the backend. */
+- (void)failWriteWithError:(NSError *_Nullable)error;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 344 - 0
Firestore/Example/Tests/SpecTests/FSTMockDatastore.m

@@ -0,0 +1,344 @@
+/*
+ * 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 "FSTMockDatastore.h"
+
+#import "Auth/FSTEmptyCredentialsProvider.h"
+#import "Core/FSTDatabaseInfo.h"
+#import "Core/FSTSnapshotVersion.h"
+#import "Local/FSTQueryData.h"
+#import "Model/FSTDatabaseID.h"
+#import "Model/FSTMutation.h"
+#import "Util/FSTAssert.h"
+#import "Util/FSTLogger.h"
+
+#import "FSTWatchChange+Testing.h"
+
+@class GRPCProtoCall;
+
+NS_ASSUME_NONNULL_BEGIN
+
+#pragma mark - FSTMockWatchStream
+
+@interface FSTMockWatchStream : FSTWatchStream
+
+- (instancetype)initWithDatabase:(FSTDatabaseInfo *)database
+             workerDispatchQueue:(FSTDispatchQueue *)workerDispatchQueue
+                     credentials:(id<FSTCredentialsProvider>)credentials
+                        delegate:(id<FSTWatchStreamDelegate>)delegate NS_DESIGNATED_INITIALIZER;
+
+- (instancetype)initWithDatabase:(FSTDatabaseInfo *)database
+             workerDispatchQueue:(FSTDispatchQueue *)workerDispatchQueue
+                     credentials:(id<FSTCredentialsProvider>)credentials
+            responseMessageClass:(Class)responseMessageClass
+                        delegate:(id<FSTWatchStreamDelegate>)delegate NS_UNAVAILABLE;
+
+@property(nonatomic, assign) BOOL open;
+
+@property(nonatomic, strong, readonly)
+    NSMutableDictionary<FSTBoxedTargetID *, FSTQueryData *> *activeTargets;
+
+@end
+
+@implementation FSTMockWatchStream
+
+- (instancetype)initWithDatabase:(FSTDatabaseInfo *)database
+             workerDispatchQueue:(FSTDispatchQueue *)workerDispatchQueue
+                     credentials:(id<FSTCredentialsProvider>)credentials
+                        delegate:(id<FSTWatchStreamDelegate>)delegate {
+  self = [super initWithDatabase:database
+             workerDispatchQueue:workerDispatchQueue
+                     credentials:credentials
+            responseMessageClass:[FSTWatchChange class]
+                        delegate:delegate];
+  if (self) {
+    FSTAssert(database, @"Database must not be nil");
+    _activeTargets = [NSMutableDictionary dictionary];
+  }
+  return self;
+}
+
+#pragma mark - Overridden FSTWatchStream methods.
+
+- (void)start {
+  FSTAssert(!self.open, @"Trying to start already started watch stream");
+  self.open = YES;
+  [self handleStreamOpen];
+}
+
+- (BOOL)isOpen {
+  return self.open;
+}
+
+- (BOOL)isStarted {
+  return self.open;
+}
+
+- (void)handleStreamOpen {
+  [self.delegate watchStreamDidOpen];
+}
+
+- (void)watchQuery:(FSTQueryData *)query {
+  FSTLog(@"watchQuery: %d: %@", query.targetID, query.query);
+  // Snapshot version is ignored on the wire
+  FSTQueryData *sentQueryData =
+      [query queryDataByReplacingSnapshotVersion:[FSTSnapshotVersion noVersion]
+                                     resumeToken:query.resumeToken];
+  self.activeTargets[@(query.targetID)] = sentQueryData;
+}
+
+- (void)unwatchTargetID:(FSTTargetID)targetID {
+  FSTLog(@"unwatchTargetID: %d", targetID);
+  [self.activeTargets removeObjectForKey:@(targetID)];
+}
+
+- (void)failStreamWithError:(NSError *)error {
+  self.open = NO;
+  [self.delegate watchStreamDidClose:error];
+}
+
+#pragma mark - Helper methods.
+
+- (void)writeWatchChange:(FSTWatchChange *)change snapshotVersion:(FSTSnapshotVersion *)snap {
+  if ([change isKindOfClass:[FSTWatchTargetChange class]]) {
+    FSTWatchTargetChange *targetChange = (FSTWatchTargetChange *)change;
+    if (targetChange.cause) {
+      for (NSNumber *targetID in targetChange.targetIDs) {
+        if (!self.activeTargets[targetID]) {
+          // Technically removing an unknown target is valid (e.g. it could race with a
+          // server-side removal), but we want to pay extra careful attention in tests
+          // that we only remove targets we listened too.
+          FSTFail(@"Removing a non-active target");
+        }
+        [self.activeTargets removeObjectForKey:targetID];
+      }
+    }
+  }
+  [self.delegate watchStreamDidChange:change snapshotVersion:snap];
+}
+
+@end
+
+#pragma mark - FSTMockWriteStream
+
+@interface FSTMockWriteStream : FSTWriteStream
+
+- (instancetype)initWithDatabase:(FSTDatabaseInfo *)database
+             workerDispatchQueue:(FSTDispatchQueue *)workerDispatchQueue
+                     credentials:(id<FSTCredentialsProvider>)credentials
+                        delegate:(id<FSTWriteStreamDelegate>)delegate NS_DESIGNATED_INITIALIZER;
+
+- (instancetype)initWithDatabase:(FSTDatabaseInfo *)database
+             workerDispatchQueue:(FSTDispatchQueue *)workerDispatchQueue
+                     credentials:(id<FSTCredentialsProvider>)credentials
+            responseMessageClass:(Class)responseMessageClass
+                        delegate:(id<FSTWatchStreamDelegate>)delegate NS_UNAVAILABLE;
+
+@property(nonatomic, assign) BOOL open;
+@property(nonatomic, strong, readonly) NSMutableArray<NSArray<FSTMutation *> *> *sentMutations;
+
+@end
+
+@implementation FSTMockWriteStream
+
+- (instancetype)initWithDatabase:(FSTDatabaseInfo *)database
+             workerDispatchQueue:(FSTDispatchQueue *)workerDispatchQueue
+                     credentials:(id<FSTCredentialsProvider>)credentials
+                        delegate:(id<FSTWriteStreamDelegate>)delegate {
+  self = [super initWithDatabase:database
+             workerDispatchQueue:workerDispatchQueue
+                     credentials:credentials
+            responseMessageClass:[FSTMutationResult class]
+                        delegate:delegate];
+  if (self) {
+    _sentMutations = [NSMutableArray array];
+  }
+  return self;
+}
+
+#pragma mark - Overridden FSTWriteStream methods.
+
+- (void)start {
+  FSTAssert(!self.open, @"Trying to start already started write stream");
+  self.open = YES;
+  [self.sentMutations removeAllObjects];
+  [self handleStreamOpen];
+}
+
+- (BOOL)isOpen {
+  return self.open;
+}
+
+- (BOOL)isStarted {
+  return self.open;
+}
+
+- (void)writeHandshake {
+  self.handshakeComplete = YES;
+  [self.delegate writeStreamDidCompleteHandshake];
+}
+
+- (void)writeMutations:(NSArray<FSTMutation *> *)mutations {
+  [self.sentMutations addObject:mutations];
+}
+
+- (void)handleStreamOpen {
+  [self.delegate writeStreamDidOpen];
+}
+
+#pragma mark - Helper methods.
+
+/** Injects a write ack as though it had come from the backend in response to a write. */
+- (void)ackWriteWithVersion:(FSTSnapshotVersion *)commitVersion
+            mutationResults:(NSArray<FSTMutationResult *> *)results {
+  [self.delegate writeStreamDidReceiveResponseWithVersion:commitVersion mutationResults:results];
+}
+
+/** Injects a failed write response as though it had come from the backend. */
+- (void)failStreamWithError:(NSError *)error {
+  self.open = NO;
+  [self.delegate writeStreamDidClose:error];
+}
+
+/**
+ * Returns the next write that was "sent to the backend", failing if there are no queued sent
+ */
+- (NSArray<FSTMutation *> *)nextSentWrite {
+  FSTAssert(self.sentMutations.count > 0,
+            @"Writes need to happen before you can call nextSentWrite.");
+  NSArray<FSTMutation *> *result = [self.sentMutations objectAtIndex:0];
+  [self.sentMutations removeObjectAtIndex:0];
+  return result;
+}
+
+/**
+ * Returns the number of mutations that have been sent to the backend but not retrieved via
+ * nextSentWrite yet.
+ */
+- (int)sentMutationsCount {
+  return (int)self.sentMutations.count;
+}
+
+@end
+
+#pragma mark - FSTMockDatastore
+
+@interface FSTMockDatastore ()
+@property(nonatomic, strong, nullable) FSTMockWatchStream *watchStream;
+@property(nonatomic, strong, nullable) FSTMockWriteStream *writeStream;
+
+/** Properties implemented in FSTDatastore that are nonpublic. */
+@property(nonatomic, strong, readonly) FSTDispatchQueue *workerDispatchQueue;
+@property(nonatomic, strong, readonly) id<FSTCredentialsProvider> credentials;
+
+@end
+
+@implementation FSTMockDatastore
+
++ (instancetype)mockDatastoreWithWorkerDispatchQueue:(FSTDispatchQueue *)workerDispatchQueue {
+  FSTDatabaseID *databaseID = [FSTDatabaseID databaseIDWithProject:@"project" database:@"database"];
+  FSTDatabaseInfo *databaseInfo = [FSTDatabaseInfo databaseInfoWithDatabaseID:databaseID
+                                                               persistenceKey:@"persistence"
+                                                                         host:@"host"
+                                                                   sslEnabled:NO];
+
+  FSTEmptyCredentialsProvider *credentials = [[FSTEmptyCredentialsProvider alloc] init];
+
+  return [[FSTMockDatastore alloc] initWithDatabaseInfo:databaseInfo
+                                    workerDispatchQueue:workerDispatchQueue
+                                            credentials:credentials];
+}
+
+#pragma mark - Overridden FSTDatastore methods.
+
+- (FSTWatchStream *)createWatchStreamWithDelegate:(id<FSTWatchStreamDelegate>)delegate {
+  FSTAssert(self.databaseInfo, @"DatabaseInfo must not be nil");
+  self.watchStream = [[FSTMockWatchStream alloc] initWithDatabase:self.databaseInfo
+                                              workerDispatchQueue:self.workerDispatchQueue
+                                                      credentials:self.credentials
+                                                         delegate:delegate];
+  return self.watchStream;
+}
+
+- (FSTWriteStream *)createWriteStreamWithDelegate:(id<FSTWriteStreamDelegate>)delegate {
+  FSTAssert(self.databaseInfo, @"DatabaseInfo must not be nil");
+  self.writeStream = [[FSTMockWriteStream alloc] initWithDatabase:self.databaseInfo
+                                              workerDispatchQueue:self.workerDispatchQueue
+                                                      credentials:self.credentials
+                                                         delegate:delegate];
+  return self.writeStream;
+}
+
+- (void)authorizeAndStartRPC:(GRPCProtoCall *)rpc completion:(FSTVoidErrorBlock)completion {
+  FSTFail(@"FSTMockDatastore shouldn't be starting any RPCs.");
+}
+
+#pragma mark - Method exposed for tests to call.
+
+- (NSArray<FSTMutation *> *)nextSentWrite {
+  return [self.writeStream nextSentWrite];
+}
+
+- (int)writesSent {
+  return [self.writeStream sentMutationsCount];
+}
+
+- (void)ackWriteWithVersion:(FSTSnapshotVersion *)commitVersion
+            mutationResults:(NSArray<FSTMutationResult *> *)results {
+  [self.writeStream ackWriteWithVersion:commitVersion mutationResults:results];
+}
+
+- (void)failWriteWithError:(NSError *_Nullable)error {
+  [self.writeStream failStreamWithError:error];
+}
+
+- (void)writeWatchTargetAddedWithTargetIDs:(NSArray<FSTBoxedTargetID *> *)targetIDs {
+  FSTWatchTargetChange *change =
+      [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateAdded
+                                  targetIDs:targetIDs
+                                      cause:nil];
+  [self writeWatchChange:change snapshotVersion:[FSTSnapshotVersion noVersion]];
+}
+
+- (void)writeWatchCurrentWithTargetIDs:(NSArray<FSTBoxedTargetID *> *)targetIDs
+                       snapshotVersion:(FSTSnapshotVersion *)snapshotVersion
+                           resumeToken:(NSData *)resumeToken {
+  FSTWatchTargetChange *change =
+      [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent
+                                  targetIDs:targetIDs
+                                resumeToken:resumeToken];
+  [self writeWatchChange:change snapshotVersion:snapshotVersion];
+}
+
+- (void)writeWatchChange:(FSTWatchChange *)change snapshotVersion:(FSTSnapshotVersion *)snap {
+  [self.watchStream writeWatchChange:change snapshotVersion:snap];
+}
+
+- (void)failWatchStreamWithError:(NSError *)error {
+  [self.watchStream failStreamWithError:error];
+}
+
+- (NSDictionary<FSTBoxedTargetID *, FSTQueryData *> *)activeTargets {
+  return [self.watchStream.activeTargets copy];
+}
+
+- (BOOL)isWatchStreamOpen {
+  return self.watchStream.isOpen;
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 46 - 0
Firestore/Example/Tests/SpecTests/FSTSpecTests.h

@@ -0,0 +1,46 @@
+/*
+ * 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 <XCTest/XCTest.h>
+
+@protocol FSTPersistence;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * FSTSpecTests run a set of portable event specifications from JSON spec files against a
+ * special isolated version of the Firestore client that allows precise control over when events
+ * are delivered. This allows us to test client behavior in a very reliable, deterministic way,
+ * including edge cases that would be difficult to reliably reproduce in a full integration test.
+ *
+ * Both events from user code (adding/removing listens, performing mutations) and events from the
+ * Datastore are simulated, while installing as much of the system in between as possible.
+ *
+ * FSTSpecTests is an abstract base class that must be subclassed to test against a specific local
+ * store implementation. To create a new variant of FSTSpecTests:
+ *
+ * + Subclass FSTSpecTests
+ * + override -persistence to create and return an appropriate id<FSTPersistence> implementation.
+ */
+@interface FSTSpecTests : XCTestCase
+
+/** Creates and returns an appropriate id<FSTPersistence> implementation. */
+- (id<FSTPersistence>)persistence;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 642 - 0
Firestore/Example/Tests/SpecTests/FSTSpecTests.m

@@ -0,0 +1,642 @@
+/*
+ * 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 "FSTSpecTests.h"
+
+#import <GRPCClient/GRPCCall.h>
+
+#import "Auth/FSTUser.h"
+#import "Core/FSTEventManager.h"
+#import "Core/FSTQuery.h"
+#import "Core/FSTSnapshotVersion.h"
+#import "Core/FSTViewSnapshot.h"
+#import "Firestore/FIRFirestoreErrors.h"
+#import "Local/FSTEagerGarbageCollector.h"
+#import "Local/FSTNoOpGarbageCollector.h"
+#import "Local/FSTPersistence.h"
+#import "Local/FSTQueryData.h"
+#import "Model/FSTDocument.h"
+#import "Model/FSTDocumentKey.h"
+#import "Model/FSTFieldValue.h"
+#import "Model/FSTMutation.h"
+#import "Model/FSTPath.h"
+#import "Remote/FSTExistenceFilter.h"
+#import "Remote/FSTWatchChange.h"
+#import "Util/FSTAssert.h"
+#import "Util/FSTClasses.h"
+#import "Util/FSTLogger.h"
+
+#import "FSTHelpers.h"
+#import "FSTSyncEngineTestDriver.h"
+#import "FSTWatchChange+Testing.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+// Disables all other tests; useful for debugging. Multiple tests can have this tag and they'll all
+// be run (but all others won't).
+static NSString *const kExclusiveTag = @"exclusive";
+
+// A tag for tests that should be excluded from execution (on iOS), useful to allow the platforms
+// to temporarily diverge.
+static NSString *const kNoIOSTag = @"no-ios";
+
+@interface FSTSpecTests ()
+@property(nonatomic, strong) FSTSyncEngineTestDriver *driver;
+
+// Some config info for the currently running spec; used when restarting the driver (for doRestart).
+@property(nonatomic, assign) BOOL GCEnabled;
+@property(nonatomic, strong) id<FSTPersistence> driverPersistence;
+@end
+
+@implementation FSTSpecTests
+
+- (id<FSTPersistence>)persistence {
+  @throw FSTAbstractMethodException();  // NOLINT
+}
+
+- (void)setUpForSpecWithConfig:(NSDictionary *)config {
+  // Store persistence / GCEnabled so we can re-use it in doRestart.
+  self.driverPersistence = [self persistence];
+  NSNumber *GCEnabled = config[@"useGarbageCollection"];
+  self.GCEnabled = [GCEnabled boolValue];
+  self.driver = [[FSTSyncEngineTestDriver alloc] initWithPersistence:self.driverPersistence
+                                                    garbageCollector:self.garbageCollector];
+  [self.driver start];
+}
+
+- (void)tearDownForSpec {
+  [self.driver shutdown];
+  [self.driverPersistence shutdown];
+}
+
+/**
+ * Creates the appropriate garbage collector for the test configuration: an eager collector if
+ * GC is enabled or a no-op collector otherwise.
+ */
+- (id<FSTGarbageCollector>)garbageCollector {
+  return self.GCEnabled ? [[FSTEagerGarbageCollector alloc] init]
+                        : [[FSTNoOpGarbageCollector alloc] init];
+}
+
+/**
+ * Xcode will run tests from any class that extends XCTestCase, but this doesn't work for
+ * FSTSpecTests since it is incomplete without the implementations supplied by its subclasses.
+ */
+- (BOOL)isTestBaseClass {
+  return [self class] == [FSTSpecTests class];
+}
+
+#pragma mark - Methods for constructing objects from specs.
+
+- (nullable FSTQuery *)parseQuery:(id)querySpec {
+  if ([querySpec isKindOfClass:[NSString class]]) {
+    return [FSTQuery queryWithPath:[FSTResourcePath pathWithString:querySpec]];
+  } else if ([querySpec isKindOfClass:[NSDictionary class]]) {
+    NSDictionary *queryDict = (NSDictionary *)querySpec;
+    NSString *path = queryDict[@"path"];
+    __block FSTQuery *query = [FSTQuery queryWithPath:[FSTResourcePath pathWithString:path]];
+    if (queryDict[@"limit"]) {
+      NSNumber *limit = queryDict[@"limit"];
+      query = [query queryBySettingLimit:limit.integerValue];
+    }
+    if (queryDict[@"filters"]) {
+      NSArray *filters = queryDict[@"filters"];
+      [filters enumerateObjectsUsingBlock:^(NSArray *_Nonnull filter, NSUInteger idx,
+                                            BOOL *_Nonnull stop) {
+        query = [query queryByAddingFilter:FSTTestFilter(filter[0], filter[1], filter[2])];
+      }];
+    }
+    if (queryDict[@"orderBys"]) {
+      NSArray *orderBys = queryDict[@"orderBys"];
+      [orderBys enumerateObjectsUsingBlock:^(NSArray *_Nonnull orderBy, NSUInteger idx,
+                                             BOOL *_Nonnull stop) {
+        query = [query queryByAddingSortOrder:FSTTestOrderBy(orderBy[0], orderBy[1])];
+      }];
+    }
+    return query;
+  } else {
+    XCTFail(@"Invalid query: %@", querySpec);
+    return nil;
+  }
+}
+
+- (FSTSnapshotVersion *)parseVersion:(NSNumber *_Nullable)version {
+  return FSTTestVersion(version.longLongValue);
+}
+
+- (FSTDocumentViewChange *)parseChange:(NSArray *)change ofType:(FSTDocumentViewChangeType)type {
+  BOOL hasMutations = NO;
+  for (NSUInteger i = 3; i < change.count; ++i) {
+    if ([change[i] isEqual:@"local"]) {
+      hasMutations = YES;
+    }
+  }
+  NSNumber *version = change[1];
+  FSTDocument *doc = FSTTestDoc(change[0], version.longLongValue, change[2], hasMutations);
+  return [FSTDocumentViewChange changeWithDocument:doc type:type];
+}
+
+#pragma mark - Methods for doing the steps of the spec test.
+
+- (void)doListen:(NSArray *)listenSpec {
+  FSTQuery *query = [self parseQuery:listenSpec[1]];
+  FSTTargetID actualID = [self.driver addUserListenerWithQuery:query];
+
+  FSTTargetID expectedID = [listenSpec[0] intValue];
+  XCTAssertEqual(actualID, expectedID);
+}
+
+- (void)doUnlisten:(NSArray *)unlistenSpec {
+  FSTQuery *query = [self parseQuery:unlistenSpec[1]];
+  [self.driver removeUserListenerWithQuery:query];
+}
+
+- (void)doSet:(NSArray *)setSpec {
+  [self.driver writeUserMutation:FSTTestSetMutation(setSpec[0], setSpec[1])];
+}
+
+- (void)doPatch:(NSArray *)patchSpec {
+  [self.driver writeUserMutation:FSTTestPatchMutation(patchSpec[0], patchSpec[1], nil)];
+}
+
+- (void)doDelete:(NSString *)key {
+  [self.driver writeUserMutation:FSTTestDeleteMutation(key)];
+}
+
+- (void)doWatchAck:(NSArray<NSNumber *> *)ackedTargets snapshot:(NSNumber *)watchSnapshot {
+  FSTWatchTargetChange *change =
+      [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateAdded
+                                  targetIDs:ackedTargets
+                                      cause:nil];
+  [self.driver receiveWatchChange:change snapshotVersion:[self parseVersion:watchSnapshot]];
+}
+
+- (void)doWatchCurrent:(NSArray<id> *)currentSpec snapshot:(NSNumber *)watchSnapshot {
+  NSArray<NSNumber *> *currentTargets = currentSpec[0];
+  NSData *resumeToken = [currentSpec[1] dataUsingEncoding:NSUTF8StringEncoding];
+  FSTWatchTargetChange *change =
+      [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateCurrent
+                                  targetIDs:currentTargets
+                                resumeToken:resumeToken];
+  [self.driver receiveWatchChange:change snapshotVersion:[self parseVersion:watchSnapshot]];
+}
+
+- (void)doWatchRemove:(NSDictionary *)watchRemoveSpec snapshot:(NSNumber *)watchSnapshot {
+  NSError *error = nil;
+  NSDictionary *cause = watchRemoveSpec[@"cause"];
+  if (cause) {
+    int code = ((NSNumber *)cause[@"code"]).intValue;
+    NSDictionary *userInfo = @{
+      NSLocalizedDescriptionKey : @"Error from watchRemove.",
+    };
+    error = [NSError errorWithDomain:FIRFirestoreErrorDomain code:code userInfo:userInfo];
+  }
+  FSTWatchTargetChange *change =
+      [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateRemoved
+                                  targetIDs:watchRemoveSpec[@"targetIds"]
+                                      cause:error];
+  [self.driver receiveWatchChange:change snapshotVersion:[self parseVersion:watchSnapshot]];
+  // Unlike web, the FSTMockDatastore detects a watch removal with cause and will remove active
+  // targets
+}
+
+- (void)doWatchEntity:(NSDictionary *)watchEntity snapshot:(NSNumber *_Nullable)watchSnapshot {
+  if (watchEntity[@"docs"]) {
+    FSTAssert(!watchEntity[@"doc"], @"Exactly one of |doc| or |docs| needs to be set.");
+    int count = 0;
+    NSArray *docs = watchEntity[@"docs"];
+    for (NSDictionary *doc in docs) {
+      count++;
+      bool isLast = (count == docs.count);
+      NSMutableDictionary *watchSpec = [NSMutableDictionary dictionary];
+      watchSpec[@"doc"] = doc;
+      if (watchEntity[@"targets"]) {
+        watchSpec[@"targets"] = watchEntity[@"targets"];
+      }
+      if (watchEntity[@"removedTargets"]) {
+        watchSpec[@"removedTargets"] = watchEntity[@"removedTargets"];
+      }
+      NSNumber *_Nullable version = nil;
+      if (isLast) {
+        version = watchSnapshot;
+      }
+      [self doWatchEntity:watchSpec snapshot:version];
+    }
+  } else if (watchEntity[@"doc"]) {
+    NSArray *docSpec = watchEntity[@"doc"];
+    FSTDocumentKey *key = [FSTDocumentKey keyWithPathString:docSpec[0]];
+    FSTObjectValue *value = FSTTestObjectValue(docSpec[2]);
+    FSTSnapshotVersion *version = [self parseVersion:docSpec[1]];
+    FSTMaybeDocument *doc =
+        [FSTDocument documentWithData:value key:key version:version hasLocalMutations:NO];
+    FSTWatchChange *change =
+        [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:watchEntity[@"targets"]
+                                                removedTargetIDs:watchEntity[@"removedTargets"]
+                                                     documentKey:doc.key
+                                                        document:doc];
+    [self.driver receiveWatchChange:change snapshotVersion:[self parseVersion:watchSnapshot]];
+  } else if (watchEntity[@"key"]) {
+    FSTDocumentKey *docKey = [FSTDocumentKey keyWithPathString:watchEntity[@"key"]];
+    FSTWatchChange *change =
+        [[FSTDocumentWatchChange alloc] initWithUpdatedTargetIDs:@[]
+                                                removedTargetIDs:watchEntity[@"removedTargets"]
+                                                     documentKey:docKey
+                                                        document:nil];
+    [self.driver receiveWatchChange:change snapshotVersion:[self parseVersion:watchSnapshot]];
+  } else {
+    FSTFail(@"Either key, doc or docs must be set.");
+  }
+}
+
+- (void)doWatchFilter:(NSArray *)watchFilter snapshot:(NSNumber *_Nullable)watchSnapshot {
+  NSArray<NSNumber *> *targets = watchFilter[0];
+  FSTAssert(targets.count == 1, @"ExistenceFilters currently support exactly one target only.");
+
+  int keyCount = watchFilter.count == 0 ? 0 : (int)watchFilter.count - 1;
+
+  // TODO(dimond): extend this with different existence filters over time.
+  FSTExistenceFilter *filter = [FSTExistenceFilter filterWithCount:keyCount];
+  FSTExistenceFilterWatchChange *change =
+      [FSTExistenceFilterWatchChange changeWithFilter:filter targetID:targets[0].intValue];
+  [self.driver receiveWatchChange:change snapshotVersion:[self parseVersion:watchSnapshot]];
+}
+
+- (void)doWatchReset:(NSArray<NSNumber *> *)watchReset snapshot:(NSNumber *_Nullable)watchSnapshot {
+  FSTWatchTargetChange *change =
+      [FSTWatchTargetChange changeWithState:FSTWatchTargetChangeStateReset
+                                  targetIDs:watchReset
+                                      cause:nil];
+  [self.driver receiveWatchChange:change snapshotVersion:[self parseVersion:watchSnapshot]];
+}
+
+- (void)doWatchStreamClose:(NSDictionary *)closeSpec {
+  NSDictionary *errorSpec = closeSpec[@"error"];
+  int code = ((NSNumber *)(errorSpec[@"code"])).intValue;
+  [self.driver receiveWatchStreamError:code userInfo:errorSpec];
+}
+
+- (void)doWriteAck:(NSDictionary *)spec {
+  FSTSnapshotVersion *version = [self parseVersion:spec[@"version"]];
+  NSNumber *expectUserCallback = spec[@"expectUserCallback"];
+
+  FSTMutationResult *mutationResult =
+      [[FSTMutationResult alloc] initWithVersion:version transformResults:nil];
+  FSTOutstandingWrite *write =
+      [self.driver receiveWriteAckWithVersion:version mutationResults:@[ mutationResult ]];
+
+  if (expectUserCallback.boolValue) {
+    FSTAssert(write.done, @"Write should be done");
+    FSTAssert(!write.error, @"Ack should not fail");
+  }
+}
+
+- (void)doFailWrite:(NSDictionary *)spec {
+  NSDictionary *errorSpec = spec[@"error"];
+  NSNumber *expectUserCallback = spec[@"expectUserCallback"];
+
+  int code = ((NSNumber *)(errorSpec[@"code"])).intValue;
+  FSTOutstandingWrite *write = [self.driver receiveWriteError:code userInfo:errorSpec];
+
+  if (expectUserCallback.boolValue) {
+    FSTAssert(write.done, @"Write should be done");
+    XCTAssertNotNil(write.error, @"Write should have failed");
+    XCTAssertEqualObjects(write.error.domain, FIRFirestoreErrorDomain);
+    XCTAssertEqual(write.error.code, code);
+  }
+}
+
+- (void)doChangeUser:(id)UID {
+  FSTUser *user = [UID isEqual:[NSNull null]] ? [FSTUser unauthenticatedUser]
+                                              : [[FSTUser alloc] initWithUID:UID];
+  [self.driver changeUser:user];
+}
+
+- (void)doRestart {
+  // Any outstanding user writes should be automatically re-sent, so we want to preserve them
+  // when re-creating the driver.
+  FSTOutstandingWriteQueues *outstandingWrites = self.driver.outstandingWrites;
+
+  [self.driver shutdown];
+
+  // NOTE: We intentionally don't shutdown / re-create driverPersistence, since we want to
+  // preserve the persisted state. This is a bit of a cheat since it means we're not exercising
+  // the initialization / start logic that would normally be hit, but simplifies the plumbing and
+  // allows us to run these tests against FSTMemoryPersistence as well (there would be no way to
+  // re-create FSTMemoryPersistence without losing all persisted state).
+
+  self.driver = [[FSTSyncEngineTestDriver alloc] initWithPersistence:self.driverPersistence
+                                                    garbageCollector:self.garbageCollector
+                                                         initialUser:self.driver.currentUser
+                                                   outstandingWrites:outstandingWrites];
+  [self.driver start];
+}
+
+- (void)doStep:(NSDictionary *)step {
+  if (step[@"userListen"]) {
+    [self doListen:step[@"userListen"]];
+  } else if (step[@"userUnlisten"]) {
+    [self doUnlisten:step[@"userUnlisten"]];
+  } else if (step[@"userSet"]) {
+    [self doSet:step[@"userSet"]];
+  } else if (step[@"userPatch"]) {
+    [self doPatch:step[@"userPatch"]];
+  } else if (step[@"userDelete"]) {
+    [self doDelete:step[@"userDelete"]];
+  } else if (step[@"watchAck"]) {
+    [self doWatchAck:step[@"watchAck"] snapshot:step[@"watchSnapshot"]];
+  } else if (step[@"watchCurrent"]) {
+    [self doWatchCurrent:step[@"watchCurrent"] snapshot:step[@"watchSnapshot"]];
+  } else if (step[@"watchRemove"]) {
+    [self doWatchRemove:step[@"watchRemove"] snapshot:step[@"watchSnapshot"]];
+  } else if (step[@"watchEntity"]) {
+    [self doWatchEntity:step[@"watchEntity"] snapshot:step[@"watchSnapshot"]];
+  } else if (step[@"watchFilter"]) {
+    [self doWatchFilter:step[@"watchFilter"] snapshot:step[@"watchSnapshot"]];
+  } else if (step[@"watchReset"]) {
+    [self doWatchReset:step[@"watchReset"] snapshot:step[@"watchSnapshot"]];
+  } else if (step[@"watchStreamClose"]) {
+    [self doWatchStreamClose:step[@"watchStreamClose"]];
+  } else if (step[@"watchProto"]) {
+    // watchProto isn't yet used, and it's unclear how to create arbitrary protos from JSON.
+    FSTFail(@"watchProto is not yet supported.");
+  } else if (step[@"writeAck"]) {
+    [self doWriteAck:step[@"writeAck"]];
+  } else if (step[@"failWrite"]) {
+    [self doFailWrite:step[@"failWrite"]];
+  } else if (step[@"changeUser"]) {
+    [self doChangeUser:step[@"changeUser"]];
+  } else if (step[@"restart"]) {
+    [self doRestart];
+  } else {
+    XCTFail(@"Unknown step: %@", step);
+  }
+}
+
+- (void)validateEvent:(FSTQueryEvent *)actual matches:(NSDictionary *)expected {
+  FSTQuery *expectedQuery = [self parseQuery:expected[@"query"]];
+  XCTAssertEqualObjects(actual.query, expectedQuery);
+  if ([expected[@"errorCode"] integerValue] != 0) {
+    XCTAssertNotNil(actual.error);
+    XCTAssertEqual(actual.error.code, [expected[@"errorCode"] integerValue]);
+  } else {
+    NSMutableArray *expectedChanges = [NSMutableArray array];
+    NSMutableArray *removed = expected[@"removed"];
+    for (NSArray *changeSpec in removed) {
+      [expectedChanges
+          addObject:[self parseChange:changeSpec ofType:FSTDocumentViewChangeTypeRemoved]];
+    }
+    NSMutableArray *added = expected[@"added"];
+    for (NSArray *changeSpec in added) {
+      [expectedChanges
+          addObject:[self parseChange:changeSpec ofType:FSTDocumentViewChangeTypeAdded]];
+    }
+    NSMutableArray *modified = expected[@"modified"];
+    for (NSArray *changeSpec in modified) {
+      [expectedChanges
+          addObject:[self parseChange:changeSpec ofType:FSTDocumentViewChangeTypeModified]];
+    }
+    NSMutableArray *metadata = expected[@"metadata"];
+    for (NSArray *changeSpec in metadata) {
+      [expectedChanges
+          addObject:[self parseChange:changeSpec ofType:FSTDocumentViewChangeTypeMetadata]];
+    }
+    XCTAssertEqualObjects(actual.viewSnapshot.documentChanges, expectedChanges);
+
+    BOOL expectedHasPendingWrites =
+        expected[@"hasPendingWrites"] ? [expected[@"hasPendingWrites"] boolValue] : NO;
+    BOOL expectedIsFromCache = expected[@"fromCache"] ? [expected[@"fromCache"] boolValue] : NO;
+    XCTAssertEqual(actual.viewSnapshot.hasPendingWrites, expectedHasPendingWrites,
+                   @"hasPendingWrites");
+    XCTAssertEqual(actual.viewSnapshot.isFromCache, expectedIsFromCache, @"isFromCache");
+  }
+}
+
+- (void)validateStepExpectations:(NSMutableArray *_Nullable)stepExpectations {
+  NSArray<FSTQueryEvent *> *events = self.driver.capturedEventsSinceLastCall;
+
+  if (!stepExpectations) {
+    XCTAssertEqual(events.count, 0);
+    for (FSTQueryEvent *event in events) {
+      XCTFail(@"Unexpected event: %@", event);
+    }
+    return;
+  }
+
+  events =
+      [events sortedArrayUsingComparator:^NSComparisonResult(FSTQueryEvent *q1, FSTQueryEvent *q2) {
+        return [q1.query.canonicalID compare:q2.query.canonicalID];
+      }];
+
+  XCTAssertEqual(events.count, stepExpectations.count);
+  NSUInteger i = 0;
+  for (; i < stepExpectations.count && i < events.count; ++i) {
+    [self validateEvent:events[i] matches:stepExpectations[i]];
+  }
+  for (; i < stepExpectations.count; ++i) {
+    XCTFail(@"Missing event: %@", stepExpectations[i]);
+  }
+  for (; i < events.count; ++i) {
+    XCTFail(@"Unexpected event: %@", events[i]);
+  }
+}
+
+- (void)validateStateExpectations:(nullable NSDictionary *)expected {
+  if (expected) {
+    if (expected[@"numOutstandingWrites"]) {
+      XCTAssertEqual([self.driver sentWritesCount], [expected[@"numOutstandingWrites"] intValue]);
+    }
+    if (expected[@"limboDocs"]) {
+      NSMutableSet<FSTDocumentKey *> *expectedLimboDocuments = [NSMutableSet set];
+      NSArray *docNames = expected[@"limboDocs"];
+      for (NSString *name in docNames) {
+        [expectedLimboDocuments addObject:FSTTestDocKey(name)];
+      }
+      // Update the expected limbo documents
+      self.driver.expectedLimboDocuments = expectedLimboDocuments;
+    }
+    if (expected[@"activeTargets"]) {
+      NSMutableDictionary *expectedActiveTargets = [NSMutableDictionary dictionary];
+      [expected[@"activeTargets"] enumerateKeysAndObjectsUsingBlock:^(NSString *targetIDString,
+                                                                      NSDictionary *queryData,
+                                                                      BOOL *stop) {
+        FSTTargetID targetID = [targetIDString intValue];
+        FSTQuery *query = [self parseQuery:queryData[@"query"]];
+        NSData *resumeToken = [queryData[@"resumeToken"] dataUsingEncoding:NSUTF8StringEncoding];
+        // TODO(mcg): populate the purpose of the target once it's possible to encode that in the
+        // spec tests. For now, hard-code that it's a listen despite the fact that it's not always
+        // the right value.
+        expectedActiveTargets[@(targetID)] =
+            [[FSTQueryData alloc] initWithQuery:query
+                                       targetID:targetID
+                                        purpose:FSTQueryPurposeListen
+                                snapshotVersion:[FSTSnapshotVersion noVersion]
+                                    resumeToken:resumeToken];
+      }];
+      self.driver.expectedActiveTargets = expectedActiveTargets;
+    }
+  }
+
+  // Always validate that the expected limbo docs match the actual limbo docs.
+  [self validateLimboDocuments];
+  // Always validate that the expected active targets match the actual active targets.
+  [self validateActiveTargets];
+}
+
+- (void)validateLimboDocuments {
+  // Make a copy so it can modified while checking against the expected limbo docs.
+  NSMutableDictionary<FSTDocumentKey *, FSTBoxedTargetID *> *actualLimboDocs =
+      [NSMutableDictionary dictionaryWithDictionary:self.driver.currentLimboDocuments];
+
+  // Validate that each limbo doc has an expected active target
+  [actualLimboDocs enumerateKeysAndObjectsUsingBlock:^(FSTDocumentKey *key,
+                                                       FSTBoxedTargetID *targetID, BOOL *stop) {
+    XCTAssertNotNil(self.driver.expectedActiveTargets[targetID],
+                    @"Found limbo doc without an expected active target");
+  }];
+
+  for (FSTDocumentKey *expectedLimboDoc in self.driver.expectedLimboDocuments) {
+    XCTAssertNotNil(actualLimboDocs[expectedLimboDoc],
+                    @"Expected doc to be in limbo, but was not: %@", expectedLimboDoc);
+    [actualLimboDocs removeObjectForKey:expectedLimboDoc];
+  }
+  XCTAssertTrue(actualLimboDocs.count == 0, "Unexpected docs in limbo: %@", actualLimboDocs);
+}
+
+- (void)validateActiveTargets {
+  // Create a copy so we can modify it in tests
+  NSMutableDictionary<FSTBoxedTargetID *, FSTQueryData *> *actualTargets =
+      [NSMutableDictionary dictionaryWithDictionary:self.driver.activeTargets];
+
+  [self.driver.expectedActiveTargets enumerateKeysAndObjectsUsingBlock:^(FSTBoxedTargetID *targetID,
+                                                                         FSTQueryData *queryData,
+                                                                         BOOL *stop) {
+    XCTAssertNotNil(actualTargets[targetID], @"Expected active target not found: %@", queryData);
+
+    // TODO(mcg): validate the purpose of the target once it's possible to encode that in the
+    // spec tests. For now, only validate properties that can be validated.
+    // XCTAssertEqualObjects(actualTargets[targetID], queryData);
+
+    FSTQueryData *actual = actualTargets[targetID];
+    XCTAssertEqualObjects(actual.query, queryData.query);
+    XCTAssertEqual(actual.targetID, queryData.targetID);
+    XCTAssertEqualObjects(actual.snapshotVersion, queryData.snapshotVersion);
+    XCTAssertEqualObjects(actual.resumeToken, queryData.resumeToken);
+
+    [actualTargets removeObjectForKey:targetID];
+  }];
+  XCTAssertTrue(actualTargets.count == 0, "Unexpected active targets: %@", actualTargets);
+}
+
+- (void)runSpecTestSteps:(NSArray *)steps config:(NSDictionary *)config {
+  @try {
+    [self setUpForSpecWithConfig:config];
+    for (NSDictionary *step in steps) {
+      FSTLog(@"Doing step %@", step);
+      [self doStep:step];
+      [self validateStepExpectations:step[@"expect"]];
+      [self validateStateExpectations:step[@"stateExpect"]];
+    }
+    [self.driver validateUsage];
+  } @finally {
+    // Ensure that the driver is torn down even if the test is failing due to a thrown exception so
+    // that any resources held by the driver are released. This is important when the driver is
+    // backed by LevelDB because LevelDB locks its database. If -tearDownForSpec were not called
+    // after an exception then subsequent attempts to open the LevelDB will fail, making it harder
+    // to zero in on the spec tests as a culprit.
+    [self tearDownForSpec];
+  }
+}
+
+#pragma mark - The actual test methods.
+
+- (void)testSpecTests {
+  if ([self isTestBaseClass]) return;
+
+  // Enumerate the .json files containing the spec tests.
+  NSMutableArray<NSString *> *specFiles = [NSMutableArray array];
+  NSMutableArray<NSDictionary *> *parsedSpecs = [NSMutableArray array];
+  NSBundle *bundle = [NSBundle bundleForClass:[self class]];
+  NSFileManager *fs = [NSFileManager defaultManager];
+  BOOL exclusiveMode = NO;
+  for (NSString *file in [fs enumeratorAtPath:[bundle bundlePath]]) {
+    if (![@"json" isEqual:[file pathExtension]]) {
+      continue;
+    }
+
+    // Read and parse the JSON from the file.
+    NSString *fileName = [file stringByDeletingPathExtension];
+    NSString *path = [bundle pathForResource:fileName ofType:@"json"];
+    NSData *json = [NSData dataWithContentsOfFile:path];
+    XCTAssertNotNil(json);
+    NSError *error = nil;
+    id _Nullable parsed = [NSJSONSerialization JSONObjectWithData:json options:0 error:&error];
+    XCTAssertNil(error, @"%@", error);
+    XCTAssertTrue([parsed isKindOfClass:[NSDictionary class]]);
+    NSDictionary *testDict = (NSDictionary *)parsed;
+
+    exclusiveMode = exclusiveMode || [self anyTestsAreMarkedExclusive:testDict];
+    [specFiles addObject:fileName];
+    [parsedSpecs addObject:testDict];
+  }
+
+  // Now iterate over them and run them.
+  __block bool ranAtLeastOneTest = NO;
+  for (NSUInteger i = 0; i < specFiles.count; i++) {
+    NSLog(@"Spec test file: %@", specFiles[i]);
+    // Iterate over the tests in the file and run them.
+    [parsedSpecs[i] enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
+      XCTAssertTrue([obj isKindOfClass:[NSDictionary class]]);
+      NSDictionary *testDescription = (NSDictionary *)obj;
+      NSString *describeName = testDescription[@"describeName"];
+      NSString *itName = testDescription[@"itName"];
+      NSString *name = [NSString stringWithFormat:@"%@ %@", describeName, itName];
+      NSDictionary *config = testDescription[@"config"];
+      NSArray *steps = testDescription[@"steps"];
+      NSArray<NSString *> *tags = testDescription[@"tags"];
+
+      BOOL runTest = !exclusiveMode || [tags indexOfObject:kExclusiveTag] != NSNotFound;
+      if ([tags indexOfObject:kNoIOSTag] != NSNotFound) {
+        runTest = NO;
+      }
+      if (runTest) {
+        NSLog(@"  Spec test: %@", name);
+        [self runSpecTestSteps:steps config:config];
+        ranAtLeastOneTest = YES;
+      } else {
+        NSLog(@"  [SKIPPED] Spec test: %@", name);
+      }
+    }];
+  }
+  XCTAssertTrue(ranAtLeastOneTest);
+}
+
+- (BOOL)anyTestsAreMarkedExclusive:(NSDictionary *)tests {
+  __block BOOL found = NO;
+  [tests enumerateKeysAndObjectsUsingBlock:^(id key, id obj, BOOL *stop) {
+    XCTAssertTrue([obj isKindOfClass:[NSDictionary class]]);
+    NSDictionary *testDescription = (NSDictionary *)obj;
+    NSArray<NSString *> *tags = testDescription[@"tags"];
+    if ([tags indexOfObject:kExclusiveTag] != NSNotFound) {
+      found = YES;
+      *stop = YES;
+    }
+  }];
+  return found;
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 248 - 0
Firestore/Example/Tests/SpecTests/FSTSyncEngineTestDriver.h

@@ -0,0 +1,248 @@
+/*
+ * 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"
+
+@class FSTDocumentKey;
+@class FSTMutation;
+@class FSTMutationResult;
+@class FSTQuery;
+@class FSTQueryData;
+@class FSTSnapshotVersion;
+@class FSTUser;
+@class FSTViewSnapshot;
+@class FSTWatchChange;
+@protocol FSTGarbageCollector;
+@protocol FSTPersistence;
+
+NS_ASSUME_NONNULL_BEGIN
+
+/**
+ * Interface used for object that contain exactly one of either a view snapshot or an error for the
+ * given query.
+ */
+@interface FSTQueryEvent : NSObject
+@property(nonatomic, strong) FSTQuery *query;
+@property(nonatomic, strong, nullable) FSTViewSnapshot *viewSnapshot;
+@property(nonatomic, strong, nullable) NSError *error;
+@end
+
+/** Holds an outstanding write and its result. */
+@interface FSTOutstandingWrite : NSObject
+/** The write that is outstanding. */
+@property(nonatomic, strong, readwrite) FSTMutation *write;
+/** Whether this write is done (regardless of whether it was successful or not). */
+@property(nonatomic, assign, readwrite) BOOL done;
+/** The error - if any - of this write. */
+@property(nonatomic, strong, nullable, readwrite) NSError *error;
+@end
+
+/** Mapping of user => array of FSTMutations for that user. */
+typedef NSDictionary<FSTUser *, NSArray<FSTOutstandingWrite *> *> FSTOutstandingWriteQueues;
+
+/**
+ * A test driver for FSTSyncEngine that allows simulated event delivery and capture. As much as
+ * possible, all sources of nondeterminism are removed so that test execution is consistent and
+ * reliable.
+ *
+ * FSTSyncEngineTestDriver:
+ *
+ * + constructs an FSTSyncEngine using a mocked FSTDatastore for the backend;
+ * + allows the caller to trigger events (user API calls and incoming FSTDatastore messages);
+ * + performs sequencing validation internally (e.g. that when a user mutation is initiated, the
+ *   FSTSyncEngine correctly sends it to the remote store); and
+ * + exposes the set of FSTQueryEvents generated for the caller to verify.
+ *
+ * Events come in three major flavors:
+ *
+ * + user events: simulate user API calls
+ * + watch events: simulate RPC interactions with the Watch backend
+ * + write events: simulate RPC interactions with the Streaming Write backend
+ *
+ * Each method on the driver injects a different event into the system.
+ */
+@interface FSTSyncEngineTestDriver : NSObject
+
+/**
+ * Initializes the underlying FSTSyncEngine with the given local persistence implementation and
+ * garbage collection policy.
+ */
+- (instancetype)initWithPersistence:(id<FSTPersistence>)persistence
+                   garbageCollector:(id<FSTGarbageCollector>)garbageCollector;
+
+/**
+ * Initializes the underlying FSTSyncEngine with the given local persistence implementation and
+ * a set of existing outstandingWrites (useful when your FSTPersistence object has
+ * persisted mutation queues).
+ */
+- (instancetype)initWithPersistence:(id<FSTPersistence>)persistence
+                   garbageCollector:(id<FSTGarbageCollector>)garbageCollector
+                        initialUser:(FSTUser *)initialUser
+                  outstandingWrites:(FSTOutstandingWriteQueues *)outstandingWrites
+    NS_DESIGNATED_INITIALIZER;
+
+- (instancetype)init NS_UNAVAILABLE;
+
+/** Starts the FSTSyncEngine and its underlying components. */
+- (void)start;
+
+/** Validates that the API has been used correctly after a test is complete. */
+- (void)validateUsage;
+
+/** Shuts the FSTSyncEngine down. */
+- (void)shutdown;
+
+/**
+ * Adds a listener to the FSTSyncEngine as if the user had initiated a new listen for the given
+ * query.
+ *
+ * Resulting events are captured and made available via the capturedEventsSinceLastCall method.
+ *
+ * @param query A valid query to execute against the backend.
+ * @return The target ID assigned by the system to track the query.
+ */
+- (FSTTargetID)addUserListenerWithQuery:(FSTQuery *)query;
+
+/**
+ * Removes a listener from the FSTSyncEngine as if the user had removed a listener corresponding
+ * to the given query.
+ *
+ * Resulting events are captured and made available via the capturedEventsSinceLastCall method.
+ *
+ * @param query An identical query corresponding to one passed to -addUserListenerWithQuery.
+ */
+- (void)removeUserListenerWithQuery:(FSTQuery *)query;
+
+/**
+ * Delivers a WatchChange RPC to the FSTSyncEngine as if it were received from the backend watch
+ * service, either in response to addUserListener: or removeUserListener calls or because the
+ * simulated backend has new data.
+ *
+ * Resulting events are captured and made available via the capturedEventsSinceLastCall method.
+ *
+ * @param change Any type of watch change
+ * @param snapshot A snapshot version to attach, if applicable. This should be sent when
+ *      simulating the server having sent a complete snapshot.
+ */
+- (void)receiveWatchChange:(FSTWatchChange *)change
+           snapshotVersion:(FSTSnapshotVersion *_Nullable)snapshot;
+
+/**
+ * Delivers a watch stream error as if the Streaming Watch backend has generated some kind of error.
+ *
+ * @param errorCode A FIRFirestoreErrorCode value, from FIRFirestoreErrors.h
+ * @param userInfo Any additional details that the server might have sent along with the error.
+ *     For the moment this is effectively unused, but is logged.
+ */
+- (void)receiveWatchStreamError:(int)errorCode userInfo:(NSDictionary<NSString *, id> *)userInfo;
+
+/**
+ * Performs a mutation against the FSTSyncEngine as if the user had written the mutation through
+ * the API.
+ *
+ * Also retains the mutation so that the driver can validate that the sync engine sent the mutation
+ * to the remote store before receiveWatchChange:snapshotVersion: and receiveWriteError:userInfo:
+ * events are processed.
+ *
+ * @param mutation Any type of valid mutation.
+ */
+- (void)writeUserMutation:(FSTMutation *)mutation;
+
+/**
+ * Delivers a write error as if the Streaming Write backend has generated some kind of error.
+ *
+ * For the moment write errors are usually must be in response to a mutation that has been written
+ * with writeUserMutation:. Spontaneously errors due to idle timeout, server restart, or credential
+ * expiration aren't yet supported.
+ *
+ * @param errorCode A FIRFirestoreErrorCode value, from FIRFirestoreErrors.h
+ * @param userInfo Any additional details that the server might have sent along with the error.
+ *     For the moment this is effectively unused, but is logged.
+ */
+- (FSTOutstandingWrite *)receiveWriteError:(int)errorCode
+                                  userInfo:(NSDictionary<NSString *, id> *)userInfo;
+
+/**
+ * Delivers a write acknowledgement as if the Streaming Write backend has acknowledged a write with
+ * the snapshot version at which the write was committed.
+ *
+ * @param commitVersion The snapshot version at which the simulated server has committed
+ *     the mutation. Snapshot versions must be monotonically increasing.
+ * @param mutationResults The mutation results for the write that is being acked.
+ */
+- (FSTOutstandingWrite *)receiveWriteAckWithVersion:(FSTSnapshotVersion *)commitVersion
+                                    mutationResults:(NSArray<FSTMutationResult *> *)mutationResults;
+
+/**
+ * A count of the mutations written to the write stream by the FSTSyncEngine, but not yet
+ * acknowledged via receiveWriteError: or receiveWriteAckWithVersion:mutationResults.
+ */
+@property(nonatomic, readonly) int sentWritesCount;
+
+/**
+ * Switches the FSTSyncEngine to a new user. The test driver tracks the outstanding mutations for
+ * each user, so future receiveWriteAck/Error operations will validate the write sent to the mock
+ * datastore matches the next outstanding write for that user.
+ */
+- (void)changeUser:(FSTUser *)user;
+
+/**
+ * Returns all query events generated by the FSTSyncEngine in response to the event injection
+ * methods called previously. The events are cleared after each invocation of this method.
+ */
+- (NSArray<FSTQueryEvent *> *)capturedEventsSinceLastCall;
+
+/**
+ * The writes that have been sent to the FSTSyncEngine via writeUserMutation: but not yet
+ * acknowledged by calling receiveWriteAck/Error:. They are tracked per-user.
+ *
+ * It is mostly an implementation detail used internally to validate that the writes sent to the
+ * mock backend by the FSTSyncEngine match the user mutations that initiated them.
+ *
+ * It is exposed specifically for use with the
+ * initWithPersistence:GCEnabled:outstandingWrites: initializer to test persistence
+ * scenarios where the FSTSyncEngine is restarted while the FSTPersistence implementation still has
+ * outstanding persisted mutations.
+ *
+ * Note: The size of the list for the current user will generally be the same as
+ * sentWritesCount, but not necessarily, since the FSTRemoteStore limits the number of
+ * outstanding writes to the backend at a given time.
+ */
+@property(nonatomic, strong, readonly) FSTOutstandingWriteQueues *outstandingWrites;
+
+/** The current user for the FSTSyncEngine; determines which mutation queue is active. */
+@property(nonatomic, strong, readonly) FSTUser *currentUser;
+
+/** The current set of documents in limbo. */
+@property(nonatomic, strong, readonly)
+    NSDictionary<FSTDocumentKey *, FSTBoxedTargetID *> *currentLimboDocuments;
+
+/** The expected set of documents in limbo. */
+@property(nonatomic, strong, readwrite) NSSet<FSTDocumentKey *> *expectedLimboDocuments;
+
+/** The set of active targets as observed on the watch stream. */
+@property(nonatomic, strong, readonly)
+    NSDictionary<FSTBoxedTargetID *, FSTQueryData *> *activeTargets;
+
+/** The expected set of active targets, keyed by target ID. */
+@property(nonatomic, strong, readwrite)
+    NSDictionary<FSTBoxedTargetID *, FSTQueryData *> *expectedActiveTargets;
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 291 - 0
Firestore/Example/Tests/SpecTests/FSTSyncEngineTestDriver.m

@@ -0,0 +1,291 @@
+/*
+ * 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 "FSTSyncEngineTestDriver.h"
+
+#import <GRPCClient/GRPCCall.h>
+
+#import "Auth/FSTUser.h"
+#import "Core/FSTEventManager.h"
+#import "Core/FSTQuery.h"
+#import "Core/FSTSnapshotVersion.h"
+#import "Core/FSTSyncEngine.h"
+#import "Firestore/FIRFirestoreErrors.h"
+#import "Local/FSTLocalStore.h"
+#import "Local/FSTPersistence.h"
+#import "Model/FSTMutation.h"
+#import "Remote/FSTDatastore.h"
+#import "Remote/FSTWatchChange.h"
+#import "Util/FSTAssert.h"
+#import "Util/FSTDispatchQueue.h"
+#import "Util/FSTLogger.h"
+
+#import "FSTMockDatastore.h"
+#import "FSTSyncEngine+Testing.h"
+
+NS_ASSUME_NONNULL_BEGIN
+
+@implementation FSTQueryEvent
+
+- (NSString *)description {
+  // The Query is also included in the view, so we skip it.
+  return [NSString stringWithFormat:@"<FSTQueryEvent: viewSnapshot=%@, error=%@>",
+                                    self.viewSnapshot, self.error];
+}
+
+@end
+
+@implementation FSTOutstandingWrite
+@end
+
+@interface FSTSyncEngineTestDriver ()
+
+#pragma mark - Parts of the Firestore system that the spec tests need to control.
+
+@property(nonatomic, strong, readonly) FSTMockDatastore *datastore;
+@property(nonatomic, strong, readonly) FSTEventManager *eventManager;
+@property(nonatomic, strong, readonly) FSTRemoteStore *remoteStore;
+@property(nonatomic, strong, readonly) FSTLocalStore *localStore;
+@property(nonatomic, strong, readonly) FSTSyncEngine *syncEngine;
+
+#pragma mark - Data structures for holding events sent by the watch stream.
+
+/** A block for the FSTEventAggregator to use to report events to the test. */
+@property(nonatomic, strong, readonly) void (^eventHandler)(FSTQueryEvent *);
+/** The events received by our eventHandler and not yet retrieved via capturedEventsSinceLastCall */
+@property(nonatomic, strong, readonly) NSMutableArray<FSTQueryEvent *> *events;
+/** A dictionary for tracking the listens on queries. */
+@property(nonatomic, strong, readonly)
+    NSMutableDictionary<FSTQuery *, FSTQueryListener *> *queryListeners;
+
+#pragma mark - Other data structures.
+@property(nonatomic, strong, readwrite) FSTUser *currentUser;
+
+@end
+
+@implementation FSTSyncEngineTestDriver {
+  // ivar is declared as mutable.
+  NSMutableDictionary<FSTUser *, NSMutableArray<FSTOutstandingWrite *> *> *_outstandingWrites;
+}
+
+- (instancetype)initWithPersistence:(id<FSTPersistence>)persistence
+                   garbageCollector:(id<FSTGarbageCollector>)garbageCollector {
+  return [self initWithPersistence:persistence
+                  garbageCollector:garbageCollector
+                       initialUser:[FSTUser unauthenticatedUser]
+                 outstandingWrites:@{}];
+}
+
+- (instancetype)initWithPersistence:(id<FSTPersistence>)persistence
+                   garbageCollector:(id<FSTGarbageCollector>)garbageCollector
+                        initialUser:(FSTUser *)initialUser
+                  outstandingWrites:(FSTOutstandingWriteQueues *)outstandingWrites {
+  if (self = [super init]) {
+    // Create mutable copy of outstandingWrites.
+    _outstandingWrites = [NSMutableDictionary dictionary];
+    [outstandingWrites enumerateKeysAndObjectsUsingBlock:^(
+                           FSTUser *user, NSArray<FSTOutstandingWrite *> *writes, BOOL *stop) {
+      _outstandingWrites[user] = [writes mutableCopy];
+    }];
+
+    _events = [NSMutableArray array];
+
+    // Set up the sync engine and various stores.
+    dispatch_queue_t mainQueue = dispatch_get_main_queue();
+    FSTDispatchQueue *dispatchQueue = [FSTDispatchQueue queueWith:mainQueue];
+    _localStore = [[FSTLocalStore alloc] initWithPersistence:persistence
+                                            garbageCollector:garbageCollector
+                                                 initialUser:initialUser];
+    _datastore = [FSTMockDatastore mockDatastoreWithWorkerDispatchQueue:dispatchQueue];
+
+    _remoteStore = [FSTRemoteStore remoteStoreWithLocalStore:_localStore datastore:_datastore];
+
+    _syncEngine = [[FSTSyncEngine alloc] initWithLocalStore:_localStore
+                                                remoteStore:_remoteStore
+                                                initialUser:initialUser];
+    _remoteStore.syncEngine = _syncEngine;
+    _eventManager = [FSTEventManager eventManagerWithSyncEngine:_syncEngine];
+
+    _remoteStore.onlineStateDelegate = _eventManager;
+
+    // Set up internal event tracking for the spec tests.
+    NSMutableArray<FSTQueryEvent *> *events = [NSMutableArray array];
+    _eventHandler = ^(FSTQueryEvent *e) {
+      [events addObject:e];
+    };
+    _events = events;
+
+    _queryListeners = [NSMutableDictionary dictionary];
+
+    _expectedLimboDocuments = [NSSet set];
+
+    _expectedActiveTargets = [NSDictionary dictionary];
+
+    _currentUser = initialUser;
+  }
+  return self;
+}
+
+- (void)start {
+  [self.localStore start];
+  [self.remoteStore start];
+}
+
+- (void)validateUsage {
+  // We could relax this if we found a reason to.
+  FSTAssert(self.events.count == 0,
+            @"You must clear all pending events by calling"
+             " capturedEventsSinceLastCall before calling shutdown.");
+}
+
+- (void)shutdown {
+  [self.remoteStore shutdown];
+  [self.localStore shutdown];
+}
+
+- (void)validateNextWriteSent:(FSTMutation *)expectedWrite {
+  NSArray<FSTMutation *> *request = [self.datastore nextSentWrite];
+  // Make sure the write went through the pipe like we expected it to.
+  FSTAssert(request.count == 1, @"Only single mutation requests are supported at the moment");
+  FSTMutation *actualWrite = request[0];
+  FSTAssert([actualWrite isEqual:expectedWrite],
+            @"Mock datastore received write %@ but first outstanding mutation was %@", actualWrite,
+            expectedWrite);
+  FSTLog(@"A write was sent: %@", actualWrite);
+}
+
+- (int)sentWritesCount {
+  return [self.datastore writesSent];
+}
+
+- (void)changeUser:(FSTUser *)user {
+  self.currentUser = user;
+  [self.syncEngine userDidChange:user];
+}
+
+- (FSTOutstandingWrite *)receiveWriteAckWithVersion:(FSTSnapshotVersion *)commitVersion
+                                    mutationResults:
+                                        (NSArray<FSTMutationResult *> *)mutationResults {
+  FSTOutstandingWrite *write = [self currentOutstandingWrites].firstObject;
+  [[self currentOutstandingWrites] removeObjectAtIndex:0];
+  [self validateNextWriteSent:write.write];
+
+  [self.datastore ackWriteWithVersion:commitVersion mutationResults:mutationResults];
+
+  return write;
+}
+
+- (FSTOutstandingWrite *)receiveWriteError:(int)errorCode
+                                  userInfo:(NSDictionary<NSString *, id> *)userInfo {
+  NSError *error =
+      [NSError errorWithDomain:FIRFirestoreErrorDomain code:errorCode userInfo:userInfo];
+
+  FSTOutstandingWrite *write = [self currentOutstandingWrites].firstObject;
+  [self validateNextWriteSent:write.write];
+
+  // If this is a permanent error, the mutation is not expected to be sent again so we remove it
+  // from currentOutstandingWrites.
+  if ([FSTDatastore isPermanentWriteError:error]) {
+    [[self currentOutstandingWrites] removeObjectAtIndex:0];
+  }
+
+  FSTLog(@"Failing a write.");
+  [self.datastore failWriteWithError:error];
+
+  return write;
+}
+
+- (NSArray<FSTQueryEvent *> *)capturedEventsSinceLastCall {
+  NSArray<FSTQueryEvent *> *result = [self.events copy];
+  [self.events removeAllObjects];
+  return result;
+}
+
+- (FSTTargetID)addUserListenerWithQuery:(FSTQuery *)query {
+  // TODO(dimond): Allow customizing listen options in spec tests
+  // TODO(dimond): Change spec tests to verify isFromCache on snapshots
+  FSTListenOptions *options = [[FSTListenOptions alloc] initWithIncludeQueryMetadataChanges:YES
+                                                             includeDocumentMetadataChanges:YES
+                                                                      waitForSyncWhenOnline:NO];
+  FSTQueryListener *listener = [[FSTQueryListener alloc]
+            initWithQuery:query
+                  options:options
+      viewSnapshotHandler:^(FSTViewSnapshot *_Nullable snapshot, NSError *_Nullable error) {
+        FSTQueryEvent *event = [[FSTQueryEvent alloc] init];
+        event.query = query;
+        event.viewSnapshot = snapshot;
+        event.error = error;
+        [self.events addObject:event];
+      }];
+  self.queryListeners[query] = listener;
+  return [self.eventManager addListener:listener];
+}
+
+- (void)removeUserListenerWithQuery:(FSTQuery *)query {
+  FSTQueryListener *listener = self.queryListeners[query];
+  [self.queryListeners removeObjectForKey:query];
+  [self.eventManager removeListener:listener];
+}
+
+- (void)writeUserMutation:(FSTMutation *)mutation {
+  FSTOutstandingWrite *write = [[FSTOutstandingWrite alloc] init];
+  write.write = mutation;
+  [[self currentOutstandingWrites] addObject:write];
+  FSTLog(@"sending a user write.");
+  [self.syncEngine writeMutations:@[ mutation ]
+                       completion:^(NSError *_Nullable error) {
+                         FSTLog(@"A callback was called with error: %@", error);
+                         write.done = YES;
+                         write.error = error;
+                       }];
+}
+
+- (void)receiveWatchChange:(FSTWatchChange *)change
+           snapshotVersion:(FSTSnapshotVersion *_Nullable)snapshot {
+  [self.datastore writeWatchChange:change snapshotVersion:snapshot];
+}
+
+- (void)receiveWatchStreamError:(int)errorCode userInfo:(NSDictionary<NSString *, id> *)userInfo {
+  NSError *error =
+      [NSError errorWithDomain:FIRFirestoreErrorDomain code:errorCode userInfo:userInfo];
+
+  [self.datastore failWatchStreamWithError:error];
+  // Unlike web, stream should re-open synchronously
+  FSTAssert(self.datastore.isWatchStreamOpen, @"Watch stream is open");
+}
+
+- (NSDictionary<FSTDocumentKey *, FSTBoxedTargetID *> *)currentLimboDocuments {
+  return [self.syncEngine currentLimboDocuments];
+}
+
+- (NSDictionary<FSTBoxedTargetID *, FSTQueryData *> *)activeTargets {
+  return [[self.datastore activeTargets] copy];
+}
+
+#pragma mark - Helper Methods
+
+- (NSMutableArray<FSTOutstandingWrite *> *)currentOutstandingWrites {
+  NSMutableArray<FSTOutstandingWrite *> *writes = _outstandingWrites[self.currentUser];
+  if (!writes) {
+    writes = [NSMutableArray array];
+    _outstandingWrites[self.currentUser] = writes;
+  }
+  return writes;
+}
+
+@end
+
+NS_ASSUME_NONNULL_END

+ 3 - 0
Firestore/Example/Tests/SpecTests/json/README.md

@@ -0,0 +1,3 @@
+These json files are generated from the web test sources.
+
+TODO(mikelehen): Re-add instructions for generating these.

+ 147 - 0
Firestore/Example/Tests/SpecTests/json/collection_spec_test.json

@@ -0,0 +1,147 @@
+{
+  "Events are raised after watch ack": {
+    "describeName": "Collections:",
+    "itName": "Events are raised after watch ack",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/key",
+              1000,
+              {
+                "foo": "bar"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1001"
+        ],
+        "watchSnapshot": 1001,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/key",
+                1000,
+                {
+                  "foo": "bar"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Events are raised for local sets before watch ack": {
+    "describeName": "Collections:",
+    "itName": "Events are raised for local sets before watch ack",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "userSet": [
+          "collection/key",
+          {
+            "foo": "bar"
+          }
+        ],
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/key",
+                0,
+                {
+                  "foo": "bar"
+                },
+                "local"
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": true
+          }
+        ]
+      }
+    ]
+  }
+}

+ 738 - 0
Firestore/Example/Tests/SpecTests/json/existence_filter_spec_test.json

@@ -0,0 +1,738 @@
+{
+  "Existence filter mismatch triggers re-run of query": {
+    "describeName": "Existence Filters:",
+    "itName": "Existence filter mismatch triggers re-run of query",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/1",
+              1000,
+              {
+                "v": 1
+              }
+            ],
+            [
+              "collection/2",
+              1000,
+              {
+                "v": 2
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1000"
+        ],
+        "watchSnapshot": 1000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/1",
+                1000,
+                {
+                  "v": 1
+                }
+              ],
+              [
+                "collection/2",
+                1000,
+                {
+                  "v": 2
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchFilter": [
+          [
+            2
+          ],
+          "collection/1"
+        ],
+        "watchSnapshot": 2000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            2
+          ]
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/1",
+              1000,
+              {
+                "v": 1
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-2000"
+        ],
+        "watchSnapshot": 2000,
+        "stateExpect": {
+          "limboDocs": [
+            "collection/2"
+          ],
+          "activeTargets": {
+            "1": {
+              "query": {
+                "path": "collection/2",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          1
+        ]
+      },
+      {
+        "watchCurrent": [
+          [
+            1
+          ],
+          "resume-token-2000"
+        ],
+        "watchSnapshot": 2000,
+        "stateExpect": {
+          "limboDocs": [],
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "removed": [
+              [
+                "collection/2",
+                1000,
+                {
+                  "v": 2
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Existence filter mismatch will drop resume token": {
+    "describeName": "Existence Filters:",
+    "itName": "Existence filter mismatch will drop resume token",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/1",
+              1000,
+              {
+                "v": 1
+              }
+            ],
+            [
+              "collection/2",
+              1000,
+              {
+                "v": 2
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "existence-filter-resume-token"
+        ],
+        "watchSnapshot": 1000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/1",
+                1000,
+                {
+                  "v": 1
+                }
+              ],
+              [
+                "collection/2",
+                1000,
+                {
+                  "v": 2
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchStreamClose": {
+          "error": {
+            "code": 14,
+            "message": "Simulated Backend Error"
+          }
+        },
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": "existence-filter-resume-token"
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchFilter": [
+          [
+            2
+          ],
+          "collection/1"
+        ],
+        "watchSnapshot": 2000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            2
+          ]
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/1",
+              1000,
+              {
+                "v": 1
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-2000"
+        ],
+        "watchSnapshot": 2000,
+        "stateExpect": {
+          "limboDocs": [
+            "collection/2"
+          ],
+          "activeTargets": {
+            "1": {
+              "query": {
+                "path": "collection/2",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          1
+        ]
+      },
+      {
+        "watchCurrent": [
+          [
+            1
+          ],
+          "resume-token-2000"
+        ],
+        "watchSnapshot": 2000,
+        "stateExpect": {
+          "limboDocs": [],
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "removed": [
+              [
+                "collection/2",
+                1000,
+                {
+                  "v": 2
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Existence filter limbo resolution is denied": {
+    "describeName": "Existence Filters:",
+    "itName": "Existence filter limbo resolution is denied",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/1",
+              1000,
+              {
+                "v": 1
+              }
+            ],
+            [
+              "collection/2",
+              1000,
+              {
+                "v": 2
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1000"
+        ],
+        "watchSnapshot": 1000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/1",
+                1000,
+                {
+                  "v": 1
+                }
+              ],
+              [
+                "collection/2",
+                1000,
+                {
+                  "v": 2
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchFilter": [
+          [
+            2
+          ],
+          "collection/1"
+        ],
+        "watchSnapshot": 2000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            2
+          ]
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/1",
+              1000,
+              {
+                "v": 1
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-2000"
+        ],
+        "watchSnapshot": 2000,
+        "stateExpect": {
+          "limboDocs": [
+            "collection/2"
+          ],
+          "activeTargets": {
+            "1": {
+              "query": {
+                "path": "collection/2",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            1
+          ],
+          "cause": {
+            "code": 7
+          }
+        },
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          },
+          "limboDocs": []
+        },
+        "watchSnapshot": 3000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "removed": [
+              [
+                "collection/2",
+                1000,
+                {
+                  "v": 2
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  }
+}

+ 1150 - 0
Firestore/Example/Tests/SpecTests/json/limbo_spec_test.json

@@ -0,0 +1,1150 @@
+{
+  "Limbo documents are deleted without an existence filter": {
+    "describeName": "Limbo Documents:",
+    "itName": "Limbo documents are deleted without an existence filter",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1000"
+        ],
+        "watchSnapshot": 1000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchReset": [
+          2
+        ]
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1001"
+        ],
+        "watchSnapshot": 1001,
+        "stateExpect": {
+          "limboDocs": [
+            "collection/a"
+          ],
+          "activeTargets": {
+            "1": {
+              "query": {
+                "path": "collection/a",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchAck": [
+          1
+        ]
+      },
+      {
+        "watchCurrent": [
+          [
+            1
+          ],
+          "resume-token-2"
+        ],
+        "watchSnapshot": 1002,
+        "stateExpect": {
+          "limboDocs": [],
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "removed": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Limbo documents are deleted with an existence filter": {
+    "describeName": "Limbo Documents:",
+    "itName": "Limbo documents are deleted with an existence filter",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1000"
+        ],
+        "watchSnapshot": 1000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchReset": [
+          2
+        ]
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1001"
+        ],
+        "watchSnapshot": 1001,
+        "stateExpect": {
+          "limboDocs": [
+            "collection/a"
+          ],
+          "activeTargets": {
+            "1": {
+              "query": {
+                "path": "collection/a",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchAck": [
+          1
+        ]
+      },
+      {
+        "watchFilter": [
+          [
+            1
+          ]
+        ]
+      },
+      {
+        "watchCurrent": [
+          [
+            1
+          ],
+          "resume-token-1002"
+        ],
+        "watchSnapshot": 1002,
+        "stateExpect": {
+          "limboDocs": [],
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "removed": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Limbo documents are resolved with updates": {
+    "describeName": "Limbo Documents:",
+    "itName": "Limbo documents are resolved with updates",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [
+              [
+                "key",
+                "==",
+                "a"
+              ]
+            ],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [
+                  [
+                    "key",
+                    "==",
+                    "a"
+                  ]
+                ],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1000"
+        ],
+        "watchSnapshot": 1000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [
+                [
+                  "key",
+                  "==",
+                  "a"
+                ]
+              ],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchReset": [
+          2
+        ]
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1001"
+        ],
+        "watchSnapshot": 1001,
+        "stateExpect": {
+          "limboDocs": [
+            "collection/a"
+          ],
+          "activeTargets": {
+            "1": {
+              "query": {
+                "path": "collection/a",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [
+                  [
+                    "key",
+                    "==",
+                    "a"
+                  ]
+                ],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [
+                [
+                  "key",
+                  "==",
+                  "a"
+                ]
+              ],
+              "orderBys": []
+            },
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchAck": [
+          1
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "b"
+              }
+            ]
+          ],
+          "targets": [
+            1
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            1
+          ],
+          "resume-token-1002"
+        ],
+        "watchSnapshot": 1002,
+        "stateExpect": {
+          "limboDocs": [],
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [
+                  [
+                    "key",
+                    "==",
+                    "a"
+                  ]
+                ],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [
+                [
+                  "key",
+                  "==",
+                  "a"
+                ]
+              ],
+              "orderBys": []
+            },
+            "removed": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Limbo documents are resolved with updates in different snapshot than \"current\"": {
+    "describeName": "Limbo Documents:",
+    "itName": "Limbo documents are resolved with updates in different snapshot than \"current\"",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [
+              [
+                "key",
+                "==",
+                "a"
+              ]
+            ],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [
+                  [
+                    "key",
+                    "==",
+                    "a"
+                  ]
+                ],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1000"
+        ],
+        "watchSnapshot": 1000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [
+                [
+                  "key",
+                  "==",
+                  "a"
+                ]
+              ],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "userListen": [
+          4,
+          {
+            "path": "collection",
+            "filters": [
+              [
+                "key",
+                "==",
+                "b"
+              ]
+            ],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [
+                  [
+                    "key",
+                    "==",
+                    "a"
+                  ]
+                ],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "4": {
+              "query": {
+                "path": "collection",
+                "filters": [
+                  [
+                    "key",
+                    "==",
+                    "b"
+                  ]
+                ],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchReset": [
+          2
+        ]
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1001"
+        ],
+        "watchSnapshot": 1001,
+        "stateExpect": {
+          "limboDocs": [
+            "collection/a"
+          ],
+          "activeTargets": {
+            "1": {
+              "query": {
+                "path": "collection/a",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [
+                  [
+                    "key",
+                    "==",
+                    "a"
+                  ]
+                ],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "4": {
+              "query": {
+                "path": "collection",
+                "filters": [
+                  [
+                    "key",
+                    "==",
+                    "b"
+                  ]
+                ],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [
+                [
+                  "key",
+                  "==",
+                  "a"
+                ]
+              ],
+              "orderBys": []
+            },
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchAck": [
+          4
+        ]
+      },
+      {
+        "watchAck": [
+          1
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "b"
+              }
+            ]
+          ],
+          "targets": [
+            1,
+            4
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            4
+          ],
+          "resume-token-1002"
+        ],
+        "watchSnapshot": 1002,
+        "stateExpect": {
+          "limboDocs": [],
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [
+                  [
+                    "key",
+                    "==",
+                    "a"
+                  ]
+                ],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "4": {
+              "query": {
+                "path": "collection",
+                "filters": [
+                  [
+                    "key",
+                    "==",
+                    "b"
+                  ]
+                ],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [
+                [
+                  "key",
+                  "==",
+                  "a"
+                ]
+              ],
+              "orderBys": []
+            },
+            "removed": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          },
+          {
+            "query": {
+              "path": "collection",
+              "filters": [
+                [
+                  "key",
+                  "==",
+                  "b"
+                ]
+              ],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "b"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchCurrent": [
+          [
+            1
+          ],
+          "resume-token-1003"
+        ],
+        "watchSnapshot": 1003
+      }
+    ]
+  },
+  "Document remove message will cause docs to go in limbo": {
+    "describeName": "Limbo Documents:",
+    "itName": "Document remove message will cause docs to go in limbo",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ],
+            [
+              "collection/b",
+              1001,
+              {
+                "key": "b"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1002"
+        ],
+        "watchSnapshot": 1002,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ],
+              [
+                "collection/b",
+                1001,
+                {
+                  "key": "b"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchEntity": {
+          "key": "collection/b",
+          "removedTargets": [
+            2
+          ]
+        },
+        "watchSnapshot": 1003,
+        "stateExpect": {
+          "limboDocs": [
+            "collection/b"
+          ],
+          "activeTargets": {
+            "1": {
+              "query": {
+                "path": "collection/b",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchAck": [
+          1
+        ]
+      },
+      {
+        "watchCurrent": [
+          [
+            1
+          ],
+          "resume-token-1004"
+        ],
+        "watchSnapshot": 1004,
+        "stateExpect": {
+          "limboDocs": [],
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "removed": [
+              [
+                "collection/b",
+                1001,
+                {
+                  "key": "b"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  }
+}

+ 1626 - 0
Firestore/Example/Tests/SpecTests/json/limit_spec_test.json

@@ -0,0 +1,1626 @@
+{
+  "Documents in limit are replaced by remote event": {
+    "describeName": "Limits:",
+    "itName": "Documents in limit are replaced by remote event",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "limit": 2,
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "limit": 2,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ],
+            [
+              "collection/c",
+              1001,
+              {
+                "key": "c"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1001"
+        ],
+        "watchSnapshot": 1001,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "limit": 2,
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ],
+              [
+                "collection/c",
+                1001,
+                {
+                  "key": "c"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/b",
+              1002,
+              {
+                "key": "b"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/c",
+              1001,
+              {
+                "key": "c"
+              }
+            ]
+          ],
+          "removedTargets": [
+            2
+          ]
+        },
+        "watchSnapshot": 1002,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "limit": 2,
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/b",
+                1002,
+                {
+                  "key": "b"
+                }
+              ]
+            ],
+            "removed": [
+              [
+                "collection/c",
+                1001,
+                {
+                  "key": "c"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Deleted Document in limbo in full limit query": {
+    "describeName": "Limits:",
+    "itName": "Deleted Document in limbo in full limit query",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "limit": 2,
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "limit": 2,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ],
+            [
+              "collection/b",
+              1001,
+              {
+                "key": "b"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1002"
+        ],
+        "watchSnapshot": 1002,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "limit": 2,
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ],
+              [
+                "collection/b",
+                1001,
+                {
+                  "key": "b"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchReset": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/b",
+              1001,
+              {
+                "key": "b"
+              }
+            ],
+            [
+              "collection/c",
+              1002,
+              {
+                "key": "c"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        },
+        "watchSnapshot": 2000,
+        "stateExpect": {
+          "limboDocs": [
+            "collection/a"
+          ],
+          "activeTargets": {
+            "1": {
+              "query": {
+                "path": "collection/a",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "2": {
+              "query": {
+                "path": "collection",
+                "limit": 2,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "limit": 2,
+              "filters": [],
+              "orderBys": []
+            },
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchAck": [
+          1
+        ]
+      },
+      {
+        "watchCurrent": [
+          [
+            1
+          ],
+          "resume-token-2000"
+        ],
+        "watchSnapshot": 2000,
+        "stateExpect": {
+          "limboDocs": [],
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "limit": 2,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "limit": 2,
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/c",
+                1002,
+                {
+                  "key": "c"
+                }
+              ]
+            ],
+            "removed": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Documents in limit can handle removed messages": {
+    "describeName": "Limits:",
+    "itName": "Documents in limit can handle removed messages",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "limit": 2,
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "limit": 2,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ],
+            [
+              "collection/c",
+              1001,
+              {
+                "key": "c"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1001"
+        ],
+        "watchSnapshot": 1001,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "limit": 2,
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ],
+              [
+                "collection/c",
+                1001,
+                {
+                  "key": "c"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/b",
+              1002,
+              {
+                "key": "b"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchEntity": {
+          "key": "collection/c",
+          "removedTargets": [
+            2
+          ]
+        },
+        "watchSnapshot": 1002,
+        "stateExpect": {
+          "limboDocs": [],
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "limit": 2,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "limit": 2,
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/b",
+                1002,
+                {
+                  "key": "b"
+                }
+              ]
+            ],
+            "removed": [
+              [
+                "collection/c",
+                1001,
+                {
+                  "key": "c"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Documents in limit are can handle removed messages for only one of many query": {
+    "describeName": "Limits:",
+    "itName": "Documents in limit are can handle removed messages for only one of many query",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "limit": 2,
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "limit": 2,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "userListen": [
+          4,
+          {
+            "path": "collection",
+            "limit": 3,
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "limit": 2,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "4": {
+              "query": {
+                "path": "collection",
+                "limit": 3,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchAck": [
+          4
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ],
+            [
+              "collection/c",
+              1001,
+              {
+                "key": "c"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ],
+            [
+              "collection/c",
+              1001,
+              {
+                "key": "c"
+              }
+            ]
+          ],
+          "targets": [
+            4
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1001"
+        ]
+      },
+      {
+        "watchCurrent": [
+          [
+            4
+          ],
+          "resume-token-1001"
+        ],
+        "watchSnapshot": 1001,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "limit": 2,
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ],
+              [
+                "collection/c",
+                1001,
+                {
+                  "key": "c"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          },
+          {
+            "query": {
+              "path": "collection",
+              "limit": 3,
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ],
+              [
+                "collection/c",
+                1001,
+                {
+                  "key": "c"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/b",
+              1002,
+              {
+                "key": "b"
+              }
+            ]
+          ],
+          "targets": [
+            2,
+            4
+          ]
+        }
+      },
+      {
+        "watchEntity": {
+          "key": "collection/c",
+          "removedTargets": [
+            2
+          ]
+        },
+        "watchSnapshot": 1002,
+        "stateExpect": {
+          "limboDocs": [],
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "limit": 2,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "4": {
+              "query": {
+                "path": "collection",
+                "limit": 3,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "limit": 2,
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/b",
+                1002,
+                {
+                  "key": "b"
+                }
+              ]
+            ],
+            "removed": [
+              [
+                "collection/c",
+                1001,
+                {
+                  "key": "c"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          },
+          {
+            "query": {
+              "path": "collection",
+              "limit": 3,
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/b",
+                1002,
+                {
+                  "key": "b"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Multiple docs in limbo in full limit query": {
+    "describeName": "Limits:",
+    "itName": "Multiple docs in limbo in full limit query",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "limit": 2,
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "limit": 2,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ],
+            [
+              "collection/b",
+              1001,
+              {
+                "key": "b"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1001"
+        ],
+        "watchSnapshot": 1001,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "limit": 2,
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ],
+              [
+                "collection/b",
+                1001,
+                {
+                  "key": "b"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "userListen": [
+          4,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "limit": 2,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "4": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ],
+              [
+                "collection/b",
+                1001,
+                {
+                  "key": "b"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchAck": [
+          4
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ],
+            [
+              "collection/b",
+              1001,
+              {
+                "key": "b"
+              }
+            ],
+            [
+              "collection/c",
+              1002,
+              {
+                "key": "c"
+              }
+            ],
+            [
+              "collection/d",
+              1003,
+              {
+                "key": "d"
+              }
+            ],
+            [
+              "collection/e",
+              1004,
+              {
+                "key": "e"
+              }
+            ],
+            [
+              "collection/f",
+              1005,
+              {
+                "key": "f"
+              }
+            ]
+          ],
+          "targets": [
+            4
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            4
+          ],
+          "resume-token-1005"
+        ],
+        "watchSnapshot": 1005,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/c",
+                1002,
+                {
+                  "key": "c"
+                }
+              ],
+              [
+                "collection/d",
+                1003,
+                {
+                  "key": "d"
+                }
+              ],
+              [
+                "collection/e",
+                1004,
+                {
+                  "key": "e"
+                }
+              ],
+              [
+                "collection/f",
+                1005,
+                {
+                  "key": "f"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchReset": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/e",
+              1004,
+              {
+                "key": "e"
+              }
+            ],
+            [
+              "collection/f",
+              1005,
+              {
+                "key": "f"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        },
+        "watchSnapshot": 2000,
+        "stateExpect": {
+          "limboDocs": [
+            "collection/a",
+            "collection/b"
+          ],
+          "activeTargets": {
+            "1": {
+              "query": {
+                "path": "collection/a",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "2": {
+              "query": {
+                "path": "collection",
+                "limit": 2,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "3": {
+              "query": {
+                "path": "collection/b",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "4": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "limit": 2,
+              "filters": [],
+              "orderBys": []
+            },
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchAck": [
+          1
+        ]
+      },
+      {
+        "watchCurrent": [
+          [
+            1
+          ],
+          "resume-token-2000"
+        ],
+        "watchSnapshot": 2000,
+        "stateExpect": {
+          "limboDocs": [
+            "collection/b",
+            "collection/c"
+          ],
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "limit": 2,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "3": {
+              "query": {
+                "path": "collection/b",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "4": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "5": {
+              "query": {
+                "path": "collection/c",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "removed": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          },
+          {
+            "query": {
+              "path": "collection",
+              "limit": 2,
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/c",
+                1002,
+                {
+                  "key": "c"
+                }
+              ]
+            ],
+            "removed": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            1
+          ]
+        }
+      },
+      {
+        "watchAck": [
+          3
+        ]
+      },
+      {
+        "watchCurrent": [
+          [
+            3
+          ],
+          "resume-token-2001"
+        ],
+        "watchSnapshot": 2001,
+        "stateExpect": {
+          "limboDocs": [
+            "collection/c",
+            "collection/d"
+          ],
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "limit": 2,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "4": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "5": {
+              "query": {
+                "path": "collection/c",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "7": {
+              "query": {
+                "path": "collection/d",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "removed": [
+              [
+                "collection/b",
+                1001,
+                {
+                  "key": "b"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          },
+          {
+            "query": {
+              "path": "collection",
+              "limit": 2,
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/d",
+                1003,
+                {
+                  "key": "d"
+                }
+              ]
+            ],
+            "removed": [
+              [
+                "collection/b",
+                1001,
+                {
+                  "key": "b"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            3
+          ]
+        }
+      },
+      {
+        "watchAck": [
+          5
+        ]
+      },
+      {
+        "watchCurrent": [
+          [
+            5
+          ],
+          "resume-token-2002"
+        ],
+        "watchSnapshot": 2002,
+        "stateExpect": {
+          "limboDocs": [
+            "collection/d"
+          ],
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "limit": 2,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "4": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "7": {
+              "query": {
+                "path": "collection/d",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "removed": [
+              [
+                "collection/c",
+                1002,
+                {
+                  "key": "c"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          },
+          {
+            "query": {
+              "path": "collection",
+              "limit": 2,
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/e",
+                1004,
+                {
+                  "key": "e"
+                }
+              ]
+            ],
+            "removed": [
+              [
+                "collection/c",
+                1002,
+                {
+                  "key": "c"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            5
+          ]
+        }
+      },
+      {
+        "watchAck": [
+          7
+        ]
+      },
+      {
+        "watchCurrent": [
+          [
+            7
+          ],
+          "resume-token-2003"
+        ],
+        "watchSnapshot": 2003,
+        "stateExpect": {
+          "limboDocs": [],
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "limit": 2,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "4": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "removed": [
+              [
+                "collection/d",
+                1003,
+                {
+                  "key": "d"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          },
+          {
+            "query": {
+              "path": "collection",
+              "limit": 2,
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/f",
+                1005,
+                {
+                  "key": "f"
+                }
+              ]
+            ],
+            "removed": [
+              [
+                "collection/d",
+                1003,
+                {
+                  "key": "d"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            7
+          ]
+        }
+      }
+    ]
+  }
+}

+ 1524 - 0
Firestore/Example/Tests/SpecTests/json/listen_spec_test.json

@@ -0,0 +1,1524 @@
+{
+  "Contents of query are cleared when listen is removed.": {
+    "describeName": "Listens:",
+    "itName": "Contents of query are cleared when listen is removed.",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1000"
+        ],
+        "watchSnapshot": 1000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "userUnlisten": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {}
+        }
+      },
+      {
+        "userListen": [
+          4,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "4": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      }
+    ]
+  },
+  "Contents of query update when new data is received.": {
+    "describeName": "Listens:",
+    "itName": "Contents of query update when new data is received.",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1000"
+        ],
+        "watchSnapshot": 1000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/b",
+              2000,
+              {
+                "key": "b"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        },
+        "watchSnapshot": 2000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/b",
+                2000,
+                {
+                  "key": "b"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Ensure correct query results with latency-compensated deletes": {
+    "describeName": "Listens:",
+    "itName": "Ensure correct query results with latency-compensated deletes",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userDelete": "collection/b"
+      },
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "a": true
+              }
+            ],
+            [
+              "collection/b",
+              1000,
+              {
+                "b": true
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1000"
+        ],
+        "watchSnapshot": 1000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "a": true
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "userListen": [
+          4,
+          {
+            "path": "collection",
+            "limit": 10,
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "4": {
+              "query": {
+                "path": "collection",
+                "limit": 10,
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "limit": 10,
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "a": true
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Will process removals without waiting for a consistent snapshot": {
+    "describeName": "Listens:",
+    "itName": "Will process removals without waiting for a consistent snapshot",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            2
+          ],
+          "cause": {
+            "code": 8
+          }
+        },
+        "stateExpect": {
+          "activeTargets": {}
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "errorCode": 8,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Will gracefully process failed targets": {
+    "describeName": "Listens:",
+    "itName": "Will gracefully process failed targets",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection1",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection1",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "userListen": [
+          4,
+          {
+            "path": "collection2",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection1",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            },
+            "4": {
+              "query": {
+                "path": "collection2",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchAck": [
+          4
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection1/a",
+              1000,
+              {
+                "a": true
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection2/a",
+              1001,
+              {
+                "b": true
+              }
+            ]
+          ],
+          "targets": [
+            4
+          ]
+        }
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            2
+          ],
+          "cause": {
+            "code": 8
+          }
+        },
+        "stateExpect": {
+          "activeTargets": {
+            "4": {
+              "query": {
+                "path": "collection2",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection1",
+              "filters": [],
+              "orderBys": []
+            },
+            "errorCode": 8,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchCurrent": [
+          [
+            4
+          ],
+          "resume-token-2000"
+        ],
+        "watchSnapshot": 2000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection2",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection2/a",
+                1001,
+                {
+                  "b": true
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Will gracefully handle watch stream reverting snapshots": {
+    "describeName": "Listens:",
+    "itName": "Will gracefully handle watch stream reverting snapshots",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": false
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "v": "v1000"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1000"
+        ],
+        "watchSnapshot": 1000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "v": "v1000"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              2000,
+              {
+                "v": "v2000"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        },
+        "watchSnapshot": 2000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "modified": [
+              [
+                "collection/a",
+                2000,
+                {
+                  "v": "v2000"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "userUnlisten": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {}
+        }
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            2
+          ]
+        }
+      },
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": "resume-token-1000"
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                2000,
+                {
+                  "v": "v2000"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "v": "v1000"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1000"
+        ],
+        "watchSnapshot": 1000
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              2000,
+              {
+                "v": "v2000"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        },
+        "watchSnapshot": 2000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Will gracefully handle watch stream reverting snapshots (with restart)": {
+    "describeName": "Listens:",
+    "itName": "Will gracefully handle watch stream reverting snapshots (with restart)",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": false
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "v": "v1000"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1000"
+        ],
+        "watchSnapshot": 1000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "v": "v1000"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              2000,
+              {
+                "v": "v2000"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        },
+        "watchSnapshot": 2000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "modified": [
+              [
+                "collection/a",
+                2000,
+                {
+                  "v": "v2000"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "restart": true,
+        "stateExpect": {
+          "activeTargets": {},
+          "limboDocs": []
+        }
+      },
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": "resume-token-1000"
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                2000,
+                {
+                  "v": "v2000"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "v": "v1000"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1000"
+        ],
+        "watchSnapshot": 1000
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              2000,
+              {
+                "v": "v2000"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        },
+        "watchSnapshot": 2000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Individual documents cannot revert": {
+    "describeName": "Listens:",
+    "itName": "Individual documents cannot revert",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": false
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [
+              [
+                "visible",
+                "==",
+                true
+              ]
+            ],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [
+                  [
+                    "visible",
+                    "==",
+                    true
+                  ]
+                ],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "v": "v1000",
+                "visible": true
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1000"
+        ],
+        "watchSnapshot": 1000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [
+                [
+                  "visible",
+                  "==",
+                  true
+                ]
+              ],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "v": "v1000",
+                  "visible": true
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "userUnlisten": [
+          2,
+          {
+            "path": "collection",
+            "filters": [
+              [
+                "visible",
+                "==",
+                true
+              ]
+            ],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {}
+        }
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            2
+          ]
+        }
+      },
+      {
+        "userListen": [
+          4,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "4": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "v": "v1000",
+                  "visible": true
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchAck": [
+          4
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              3000,
+              {
+                "v": "v3000",
+                "visible": false
+              }
+            ]
+          ],
+          "targets": [
+            4
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            4
+          ],
+          "resume-token-4000"
+        ],
+        "watchSnapshot": 4000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "modified": [
+              [
+                "collection/a",
+                3000,
+                {
+                  "v": "v3000",
+                  "visible": false
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "userUnlisten": [
+          4,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {}
+        }
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            4
+          ]
+        }
+      },
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [
+              [
+                "visible",
+                "==",
+                true
+              ]
+            ],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [
+                  [
+                    "visible",
+                    "==",
+                    true
+                  ]
+                ],
+                "orderBys": []
+              },
+              "resumeToken": "resume-token-1000"
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              2000,
+              {
+                "v": "v2000",
+                "visible": false
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-5000"
+        ],
+        "watchSnapshot": 5000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [
+                [
+                  "visible",
+                  "==",
+                  true
+                ]
+              ],
+              "orderBys": []
+            },
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "userUnlisten": [
+          2,
+          {
+            "path": "collection",
+            "filters": [
+              [
+                "visible",
+                "==",
+                true
+              ]
+            ],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {}
+        }
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            2
+          ]
+        }
+      },
+      {
+        "userListen": [
+          4,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "4": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": "resume-token-4000"
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                3000,
+                {
+                  "v": "v3000",
+                  "visible": false
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchAck": [
+          4
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [],
+          "targets": [
+            4
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            4
+          ],
+          "resume-token-6000"
+        ],
+        "watchSnapshot": 6000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  }
+}

+ 151 - 0
Firestore/Example/Tests/SpecTests/json/offline_spec_test.json

@@ -0,0 +1,151 @@
+{
+  "Empty queries are resolved if client goes offline": {
+    "describeName": "Offline:",
+    "itName": "Empty queries are resolved if client goes offline",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchStreamClose": {
+          "error": {
+            "code": 14,
+            "message": "Simulated Backend Error"
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchStreamClose": {
+          "error": {
+            "code": 14,
+            "message": "Simulated Backend Error"
+          }
+        }
+      },
+      {
+        "watchStreamClose": {
+          "error": {
+            "code": 14,
+            "message": "Simulated Backend Error"
+          }
+        }
+      }
+    ]
+  },
+  "A successful message delays offline status": {
+    "describeName": "Offline:",
+    "itName": "A successful message delays offline status",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchStreamClose": {
+          "error": {
+            "code": 14,
+            "message": "Simulated Backend Error"
+          }
+        }
+      },
+      {
+        "watchStreamClose": {
+          "error": {
+            "code": 14,
+            "message": "Simulated Backend Error"
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchStreamClose": {
+          "error": {
+            "code": 14,
+            "message": "Simulated Backend Error"
+          }
+        }
+      },
+      {
+        "watchStreamClose": {
+          "error": {
+            "code": 14,
+            "message": "Simulated Backend Error"
+          }
+        }
+      }
+    ]
+  }
+}

+ 155 - 0
Firestore/Example/Tests/SpecTests/json/orderby_spec_test.json

@@ -0,0 +1,155 @@
+{
+  "orderBy applies filtering based on local state": {
+    "describeName": "OrderBy:",
+    "itName": "orderBy applies filtering based on local state",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userSet": [
+          "collection/a",
+          {
+            "key": "a",
+            "sort": 1
+          }
+        ]
+      },
+      {
+        "userPatch": [
+          "collection/b",
+          {
+            "sort": 2
+          }
+        ]
+      },
+      {
+        "userSet": [
+          "collection/c",
+          {
+            "key": "b"
+          }
+        ]
+      },
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": [
+              [
+                "sort",
+                "asc"
+              ]
+            ]
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": [
+                  [
+                    "sort",
+                    "asc"
+                  ]
+                ]
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": [
+                [
+                  "sort",
+                  "asc"
+                ]
+              ]
+            },
+            "added": [
+              [
+                "collection/a",
+                0,
+                {
+                  "key": "a",
+                  "sort": 1
+                },
+                "local"
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": true
+          }
+        ]
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/b",
+              1001,
+              {
+                "key": "b"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-2000"
+        ],
+        "watchSnapshot": 2000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": [
+                [
+                  "sort",
+                  "asc"
+                ]
+              ]
+            },
+            "added": [
+              [
+                "collection/b",
+                1001,
+                {
+                  "key": "b",
+                  "sort": 2
+                },
+                "local"
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": true
+          }
+        ]
+      }
+    ]
+  }
+}

+ 858 - 0
Firestore/Example/Tests/SpecTests/json/persistence_spec_test.json

@@ -0,0 +1,858 @@
+{
+  "Local mutations are persisted and re-sent": {
+    "describeName": "Persistence:",
+    "itName": "Local mutations are persisted and re-sent",
+    "tags": [
+      "persistence"
+    ],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userSet": [
+          "collection/key1",
+          {
+            "foo": "bar"
+          }
+        ]
+      },
+      {
+        "userSet": [
+          "collection/key2",
+          {
+            "baz": "quu"
+          }
+        ]
+      },
+      {
+        "restart": true,
+        "stateExpect": {
+          "activeTargets": {},
+          "limboDocs": [],
+          "numOutstandingWrites": 2
+        }
+      },
+      {
+        "writeAck": {
+          "version": 1,
+          "expectUserCallback": false
+        }
+      },
+      {
+        "writeAck": {
+          "version": 2,
+          "expectUserCallback": false
+        },
+        "stateExpect": {
+          "numOutstandingWrites": 0
+        }
+      }
+    ]
+  },
+  "Persisted local mutations are visible to listeners": {
+    "describeName": "Persistence:",
+    "itName": "Persisted local mutations are visible to listeners",
+    "tags": [
+      "persistence"
+    ],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userSet": [
+          "collection/key1",
+          {
+            "foo": "bar"
+          }
+        ]
+      },
+      {
+        "userSet": [
+          "collection/key2",
+          {
+            "baz": "quu"
+          }
+        ]
+      },
+      {
+        "restart": true,
+        "stateExpect": {
+          "activeTargets": {},
+          "limboDocs": []
+        }
+      },
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/key1",
+                0,
+                {
+                  "foo": "bar"
+                },
+                "local"
+              ],
+              [
+                "collection/key2",
+                0,
+                {
+                  "baz": "quu"
+                },
+                "local"
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": true
+          }
+        ]
+      }
+    ]
+  },
+  "Remote documents are persisted": {
+    "describeName": "Persistence:",
+    "itName": "Remote documents are persisted",
+    "tags": [
+      "persistence"
+    ],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/key",
+              1000,
+              {
+                "foo": "bar"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1000"
+        ],
+        "watchSnapshot": 1000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/key",
+                1000,
+                {
+                  "foo": "bar"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "restart": true,
+        "stateExpect": {
+          "activeTargets": {},
+          "limboDocs": []
+        }
+      },
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": "resume-token-1000"
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/key",
+                1000,
+                {
+                  "foo": "bar"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Remote documents from watch are not GC'd": {
+    "describeName": "Persistence:",
+    "itName": "Remote documents from watch are not GC'd",
+    "tags": [
+      "persistence"
+    ],
+    "config": {
+      "useGarbageCollection": false
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/key",
+              1000,
+              {
+                "foo": "bar"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1000"
+        ],
+        "watchSnapshot": 1000,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/key",
+                1000,
+                {
+                  "foo": "bar"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "userUnlisten": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {}
+        }
+      },
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": "resume-token-1000"
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/key",
+                1000,
+                {
+                  "foo": "bar"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Remote documents from user sets are not GC'd": {
+    "describeName": "Persistence:",
+    "itName": "Remote documents from user sets are not GC'd",
+    "tags": [
+      "persistence"
+    ],
+    "config": {
+      "useGarbageCollection": false
+    },
+    "steps": [
+      {
+        "userSet": [
+          "collection/key",
+          {
+            "foo": "bar"
+          }
+        ]
+      },
+      {
+        "writeAck": {
+          "version": 1000,
+          "expectUserCallback": true
+        }
+      },
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/key",
+                0,
+                {
+                  "foo": "bar"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Mutation Queue is persisted across uid switches": {
+    "describeName": "Persistence:",
+    "itName": "Mutation Queue is persisted across uid switches",
+    "tags": [
+      "persistence"
+    ],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userSet": [
+          "users/anon",
+          {
+            "uid": "anon"
+          }
+        ]
+      },
+      {
+        "changeUser": "user1",
+        "stateExpect": {
+          "numOutstandingWrites": 0
+        }
+      },
+      {
+        "userSet": [
+          "users/user1",
+          {
+            "uid": "user1"
+          }
+        ]
+      },
+      {
+        "userSet": [
+          "users/user1",
+          {
+            "uid": "user1",
+            "extra": true
+          }
+        ]
+      },
+      {
+        "changeUser": null,
+        "stateExpect": {
+          "numOutstandingWrites": 1
+        }
+      },
+      {
+        "writeAck": {
+          "version": 1000,
+          "expectUserCallback": true
+        }
+      },
+      {
+        "changeUser": "user1",
+        "stateExpect": {
+          "numOutstandingWrites": 2
+        }
+      },
+      {
+        "writeAck": {
+          "version": 2000,
+          "expectUserCallback": true
+        }
+      },
+      {
+        "writeAck": {
+          "version": 3000,
+          "expectUserCallback": true
+        }
+      }
+    ]
+  },
+  "Mutation Queue is persisted across uid switches (with restarts)": {
+    "describeName": "Persistence:",
+    "itName": "Mutation Queue is persisted across uid switches (with restarts)",
+    "tags": [
+      "persistence"
+    ],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userSet": [
+          "users/anon",
+          {
+            "uid": "anon"
+          }
+        ]
+      },
+      {
+        "changeUser": "user1",
+        "stateExpect": {
+          "numOutstandingWrites": 0
+        }
+      },
+      {
+        "userSet": [
+          "users/user1",
+          {
+            "uid": "user1"
+          }
+        ]
+      },
+      {
+        "userSet": [
+          "users/user1",
+          {
+            "uid": "user1",
+            "extra": true
+          }
+        ]
+      },
+      {
+        "changeUser": null
+      },
+      {
+        "restart": true,
+        "stateExpect": {
+          "activeTargets": {},
+          "limboDocs": [],
+          "numOutstandingWrites": 1
+        }
+      },
+      {
+        "writeAck": {
+          "version": 1000,
+          "expectUserCallback": false
+        }
+      },
+      {
+        "changeUser": "user1"
+      },
+      {
+        "restart": true,
+        "stateExpect": {
+          "activeTargets": {},
+          "limboDocs": [],
+          "numOutstandingWrites": 2
+        }
+      },
+      {
+        "writeAck": {
+          "version": 2000,
+          "expectUserCallback": false
+        }
+      },
+      {
+        "writeAck": {
+          "version": 3000,
+          "expectUserCallback": false
+        }
+      }
+    ]
+  },
+  "Visible mutations reflect uid switches": {
+    "describeName": "Persistence:",
+    "itName": "Visible mutations reflect uid switches",
+    "tags": [
+      "persistence"
+    ],
+    "config": {
+      "useGarbageCollection": true
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "users",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "users",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "users/existing",
+              0,
+              {
+                "uid": "existing"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-500"
+        ],
+        "watchSnapshot": 500,
+        "expect": [
+          {
+            "query": {
+              "path": "users",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "users/existing",
+                0,
+                {
+                  "uid": "existing"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "userSet": [
+          "users/anon",
+          {
+            "uid": "anon"
+          }
+        ],
+        "expect": [
+          {
+            "query": {
+              "path": "users",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "users/anon",
+                0,
+                {
+                  "uid": "anon"
+                },
+                "local"
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": true
+          }
+        ]
+      },
+      {
+        "changeUser": "user1",
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "users",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": "resume-token-500"
+            }
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "users",
+              "filters": [],
+              "orderBys": []
+            },
+            "removed": [
+              [
+                "users/anon",
+                0,
+                {
+                  "uid": "anon"
+                },
+                "local"
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "userSet": [
+          "users/user1",
+          {
+            "uid": "user1"
+          }
+        ],
+        "expect": [
+          {
+            "query": {
+              "path": "users",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "users/user1",
+                0,
+                {
+                  "uid": "user1"
+                },
+                "local"
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": true
+          }
+        ]
+      },
+      {
+        "changeUser": null,
+        "expect": [
+          {
+            "query": {
+              "path": "users",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "users/anon",
+                0,
+                {
+                  "uid": "anon"
+                },
+                "local"
+              ]
+            ],
+            "removed": [
+              [
+                "users/user1",
+                0,
+                {
+                  "uid": "user1"
+                },
+                "local"
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": true
+          }
+        ]
+      }
+    ]
+  }
+}

+ 559 - 0
Firestore/Example/Tests/SpecTests/json/remote_store_spec_test.json

@@ -0,0 +1,559 @@
+{
+  "Waits for watch to remove targets": {
+    "describeName": "Remote store:",
+    "itName": "Waits for watch to remove targets",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": false
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "userUnlisten": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {}
+        }
+      },
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token"
+        ],
+        "watchSnapshot": 1000
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            2
+          ]
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1001"
+        ],
+        "watchSnapshot": 1001,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Waits for watch to ack last target add": {
+    "describeName": "Remote store:",
+    "itName": "Waits for watch to ack last target add",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": false
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "userUnlisten": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {}
+        }
+      },
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "userUnlisten": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {}
+        }
+      },
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "userUnlisten": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {}
+        }
+      },
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token"
+        ],
+        "watchSnapshot": 1000
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            2
+          ]
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/b",
+              1000,
+              {
+                "key": "b"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1001"
+        ],
+        "watchSnapshot": 1001
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            2
+          ]
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/c",
+              1000,
+              {
+                "key": "c"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1001"
+        ],
+        "watchSnapshot": 1001
+      },
+      {
+        "watchRemove": {
+          "targetIds": [
+            2
+          ]
+        }
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/d",
+              1000,
+              {
+                "key": "d"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1001"
+        ],
+        "watchSnapshot": 1001,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/d",
+                1000,
+                {
+                  "key": "d"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  },
+  "Cleans up watch state correctly": {
+    "describeName": "Remote store:",
+    "itName": "Cleans up watch state correctly",
+    "tags": [],
+    "config": {
+      "useGarbageCollection": false
+    },
+    "steps": [
+      {
+        "userListen": [
+          2,
+          {
+            "path": "collection",
+            "filters": [],
+            "orderBys": []
+          }
+        ],
+        "stateExpect": {
+          "activeTargets": {
+            "2": {
+              "query": {
+                "path": "collection",
+                "filters": [],
+                "orderBys": []
+              },
+              "resumeToken": ""
+            }
+          }
+        }
+      },
+      {
+        "watchStreamClose": {
+          "error": {
+            "code": 14,
+            "message": "Simulated Backend Error"
+          }
+        },
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "errorCode": 0,
+            "fromCache": true,
+            "hasPendingWrites": false
+          }
+        ]
+      },
+      {
+        "watchAck": [
+          2
+        ]
+      },
+      {
+        "watchEntity": {
+          "docs": [
+            [
+              "collection/a",
+              1000,
+              {
+                "key": "a"
+              }
+            ]
+          ],
+          "targets": [
+            2
+          ]
+        }
+      },
+      {
+        "watchCurrent": [
+          [
+            2
+          ],
+          "resume-token-1001"
+        ],
+        "watchSnapshot": 1001,
+        "expect": [
+          {
+            "query": {
+              "path": "collection",
+              "filters": [],
+              "orderBys": []
+            },
+            "added": [
+              [
+                "collection/a",
+                1000,
+                {
+                  "key": "a"
+                }
+              ]
+            ],
+            "errorCode": 0,
+            "fromCache": false,
+            "hasPendingWrites": false
+          }
+        ]
+      }
+    ]
+  }
+}

Unele fișiere nu au fost afișate deoarece prea multe fișiere au fost modificate în acest diff