diff --git a/packages/firestore-compat/.idea/runConfigurations/Integration_Tests__Emulator_.xml b/packages/firestore-compat/.idea/runConfigurations/Integration_Tests__Emulator_.xml
new file mode 100644
index 00000000000..b1c4b4d9ce0
--- /dev/null
+++ b/packages/firestore-compat/.idea/runConfigurations/Integration_Tests__Emulator_.xml
@@ -0,0 +1,18 @@
+
+
+ project
+
+ $PROJECT_DIR$/../../node_modules/mocha
+ $PROJECT_DIR$
+ true
+
+
+
+
+ bdd
+ --require babel-register.js --require src/index.node.ts --timeout 5000
+ PATTERN
+ test/*.test.ts
+
+
+
\ No newline at end of file
diff --git a/packages/firestore-compat/babel-register.js b/packages/firestore-compat/babel-register.js
new file mode 100644
index 00000000000..6ccf6741d21
--- /dev/null
+++ b/packages/firestore-compat/babel-register.js
@@ -0,0 +1,18 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+require('@babel/register')({ extensions: ['.js', '.ts'] });
diff --git a/packages/firestore-compat/babel.config.json b/packages/firestore-compat/babel.config.json
new file mode 100644
index 00000000000..b6b9bde4dfc
--- /dev/null
+++ b/packages/firestore-compat/babel.config.json
@@ -0,0 +1,7 @@
+{
+ "presets": [
+ "@babel/preset-typescript",
+ ["@babel/preset-env", {"targets": {"node": "10"}, "modules": "cjs"}]
+ ],
+ "plugins": ["babel-plugin-transform-import-meta"]
+}
diff --git a/packages/firestore-compat/karma.conf.js b/packages/firestore-compat/karma.conf.js
index 15b6cdce68a..1d00ed1ef94 100644
--- a/packages/firestore-compat/karma.conf.js
+++ b/packages/firestore-compat/karma.conf.js
@@ -24,7 +24,7 @@ module.exports = function (config) {
files: getTestFiles(argv),
preprocessors: {
- 'test/**/*.ts': ['webpack', 'sourcemap']
+ 'test/*.ts': ['webpack', 'sourcemap']
},
// frameworks to use
@@ -44,22 +44,8 @@ module.exports = function (config) {
* --unit and --integration command-line arguments.
*/
function getTestFiles(argv) {
- const unitTests = 'test/unit/bootstrap.ts';
- const legcayIntegrationTests = 'test/integration/bootstrap.ts';
- const liteIntegrationTests = 'test/lite/bootstrap.ts';
- if (argv.unit) {
- return [unitTests];
- } else if (argv.integration) {
- return [legcayIntegrationTests];
- } else if (argv.lite) {
- process.env.TEST_PLATFORM = 'browser_lite';
- return [liteIntegrationTests];
- } else {
- // Note that we cannot include both the firestore-exp and the legacy SDK
- // as the test runners modify the global namespace cannot be both included
- // in the same bundle.
- return [unitTests, legcayIntegrationTests];
- }
+ const integrationTests = 'test/bootstrap.ts';
+ return [integrationTests];
}
/**
diff --git a/packages/firestore-compat/package.json b/packages/firestore-compat/package.json
index 8acc6bafe2e..5863b9874ff 100644
--- a/packages/firestore-compat/package.json
+++ b/packages/firestore-compat/package.json
@@ -26,12 +26,16 @@
"scripts": {
"lint": "eslint -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'",
"lint:fix": "eslint --fix -c .eslintrc.js '**/*.ts' --ignore-path '../../.gitignore'",
- "prettier": "prettier --write '*.js' '*.ts' '@(src|test)/**/*.ts'",
+ "prettier": "prettier --write '*.js' '@(src|test)/**/*.ts'",
"build": "rollup -c ./rollup.config.js",
"build:console": "node tools/console.build.js",
"build:deps": "lerna run --scope @firebase/firestore-compat --include-dependencies build",
"build:release": "yarn build && yarn add-compat-overloads",
- "test": "echo 'tested as part of firestore'",
+ "test": "run-s lint test:all",
+ "test:ci": "node ../../scripts/run_tests_in_ci.js -s test:all",
+ "test:all": "run-p test:browser test:node",
+ "test:browser": "karma start --single-run",
+ "test:node": "mocha --require babel-register.js --require src/index.node.ts --timeout 5000 'test/*.test.ts'",
"add-compat-overloads": "ts-node-script ../../scripts/build/create-overloads.ts -i ../firestore/dist/index.d.ts -o dist/src/index.d.ts -a -r Firestore:types.FirebaseFirestore -r CollectionReference:types.CollectionReference -r DocumentReference:types.DocumentReference -r Query:types.Query -r FirebaseApp:FirebaseAppCompat --moduleToEnhance @firebase/firestore"
},
"peerDependencies": {
@@ -65,4 +69,4 @@
"bugs": {
"url": "https://github.com/firebase/firebase-js-sdk/issues"
}
-}
\ No newline at end of file
+}
diff --git a/packages/firestore-compat/test/array_transforms.test.ts b/packages/firestore-compat/test/array_transforms.test.ts
new file mode 100644
index 00000000000..02e3bc45601
--- /dev/null
+++ b/packages/firestore-compat/test/array_transforms.test.ts
@@ -0,0 +1,240 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as firestore from '@firebase/firestore-types';
+import { expect } from 'chai';
+
+import { addEqualityMatcher } from './util/equality_matcher';
+import { EventsAccumulator } from './util/events_accumulator';
+import * as firebaseExport from './util/firebase_export';
+import { apiDescribe, withTestDb, withTestDoc } from './util/helpers';
+
+addEqualityMatcher();
+
+const FieldValue = firebaseExport.FieldValue;
+
+/**
+ * Note: Transforms are tested pretty thoroughly in server_timestamp.test.ts
+ * (via set, update, transactions, nested in documents, multiple transforms
+ * together, etc.) and so these tests mostly focus on the array transform
+ * semantics.
+ */
+apiDescribe('Array Transforms:', (persistence: boolean) => {
+ // A document reference to read and write to.
+ let docRef: firestore.DocumentReference;
+
+ // Accumulator used to capture events during the test.
+ let accumulator: EventsAccumulator;
+
+ // Listener registration for a listener maintained during the course of the
+ // test.
+ let unsubscribe: () => void;
+
+ /** Writes some initialData and consumes the events generated. */
+ async function writeInitialData(
+ initialData: firestore.DocumentData
+ ): Promise {
+ await docRef.set(initialData);
+ await accumulator.awaitLocalEvent();
+ const snapshot = await accumulator.awaitRemoteEvent();
+ expect(snapshot.data()).to.deep.equal(initialData);
+ }
+
+ async function expectLocalAndRemoteEvent(
+ expected: firestore.DocumentData
+ ): Promise {
+ const localSnap = await accumulator.awaitLocalEvent();
+ expect(localSnap.data()).to.deep.equal(expected);
+ const remoteSnap = await accumulator.awaitRemoteEvent();
+ expect(remoteSnap.data()).to.deep.equal(expected);
+ }
+
+ /**
+ * Wraps a test, getting a docRef and event accumulator, and cleaning them
+ * up when done.
+ */
+ async function withTestSetup(test: () => Promise): Promise {
+ await withTestDoc(persistence, async doc => {
+ docRef = doc;
+ accumulator = new EventsAccumulator();
+ unsubscribe = docRef.onSnapshot(
+ { includeMetadataChanges: true },
+ accumulator.storeEvent
+ );
+
+ // wait for initial null snapshot to avoid potential races.
+ const snapshot = await accumulator.awaitRemoteEvent();
+ expect(snapshot.exists).to.be.false;
+ await test();
+ unsubscribe();
+ });
+ }
+
+ it('create document with arrayUnion()', async () => {
+ await withTestSetup(async () => {
+ await docRef.set({ array: FieldValue.arrayUnion(1, 2) });
+ await expectLocalAndRemoteEvent({ array: [1, 2] });
+ });
+ });
+
+ it('append to array via update()', async () => {
+ await withTestSetup(async () => {
+ await writeInitialData({ array: [1, 3] });
+ await docRef.update({ array: FieldValue.arrayUnion(2, 1, 4) });
+ await expectLocalAndRemoteEvent({ array: [1, 3, 2, 4] });
+ });
+ });
+
+ it('append to array via set(..., {merge: true})', async () => {
+ await withTestSetup(async () => {
+ await writeInitialData({ array: [1, 3] });
+ await docRef.set(
+ { array: FieldValue.arrayUnion(2, 1, 4) },
+ { merge: true }
+ );
+ await expectLocalAndRemoteEvent({ array: [1, 3, 2, 4] });
+ });
+ });
+
+ it('append object to array via update()', async () => {
+ await withTestSetup(async () => {
+ await writeInitialData({ array: [{ a: 'hi' }] });
+ await docRef.update({
+ array: FieldValue.arrayUnion({ a: 'hi' }, { a: 'bye' })
+ });
+ await expectLocalAndRemoteEvent({ array: [{ a: 'hi' }, { a: 'bye' }] });
+ });
+ });
+
+ it('remove from array via update()', async () => {
+ await withTestSetup(async () => {
+ await writeInitialData({ array: [1, 3, 1, 3] });
+ await docRef.update({ array: FieldValue.arrayRemove(1, 4) });
+ await expectLocalAndRemoteEvent({ array: [3, 3] });
+ });
+ });
+
+ it('remove from array via set(..., {merge: true})', async () => {
+ await withTestSetup(async () => {
+ await writeInitialData({ array: [1, 3, 1, 3] });
+ await docRef.set(
+ { array: FieldValue.arrayRemove(1, 4) },
+ { merge: true }
+ );
+ await expectLocalAndRemoteEvent({ array: [3, 3] });
+ });
+ });
+
+ it('remove object from array via update()', async () => {
+ await withTestSetup(async () => {
+ await writeInitialData({ array: [{ a: 'hi' }, { a: 'bye' }] });
+ await docRef.update({ array: FieldValue.arrayRemove({ a: 'hi' }) });
+ await expectLocalAndRemoteEvent({ array: [{ a: 'bye' }] });
+ });
+ });
+
+ it('arrayUnion() supports DocumentReference', async () => {
+ await withTestSetup(async () => {
+ await docRef.set({ array: FieldValue.arrayUnion(docRef) });
+ await expectLocalAndRemoteEvent({ array: [docRef] });
+ });
+ });
+
+ /**
+ * Unlike the withTestSetup() tests above, these tests intentionally avoid
+ * having any ongoing listeners so that we can test what gets stored in the
+ * offline cache based purely on the write acknowledgement (without receiving
+ * an updated document via watch). As such they also rely on persistence
+ * being enabled so documents remain in the cache after the write.
+ */
+ // eslint-disable-next-line no-restricted-properties
+ (persistence ? describe : describe.skip)('Server Application: ', () => {
+ it('set() with no cached base doc', async () => {
+ await withTestDoc(persistence, async docRef => {
+ await docRef.set({ array: FieldValue.arrayUnion(1, 2) });
+ const snapshot = await docRef.get({ source: 'cache' });
+ expect(snapshot.data()).to.deep.equal({ array: [1, 2] });
+ });
+ });
+
+ it('update() with no cached base doc', async () => {
+ let path: string | null = null;
+ // Write an initial document in an isolated Firestore instance so it's not
+ // stored in our cache
+ await withTestDoc(persistence, async docRef => {
+ path = docRef.path;
+ await docRef.set({ array: [42] });
+ });
+
+ await withTestDb(persistence, async db => {
+ const docRef = db.doc(path!);
+ await docRef.update({ array: FieldValue.arrayUnion(1, 2) });
+
+ // Nothing should be cached since it was an update and we had no base
+ // doc.
+ let errCaught = false;
+ try {
+ await docRef.get({ source: 'cache' });
+ } catch (err) {
+ expect(err.code).to.equal('unavailable');
+ errCaught = true;
+ }
+ expect(errCaught).to.be.true;
+ });
+ });
+
+ it('set(..., {merge}) with no cached based doc', async () => {
+ let path: string | null = null;
+ // Write an initial document in an isolated Firestore instance so it's not
+ // stored in our cache
+ await withTestDoc(persistence, async docRef => {
+ path = docRef.path;
+ await docRef.set({ array: [42] });
+ });
+
+ await withTestDb(persistence, async db => {
+ const docRef = db.doc(path!);
+ await docRef.set(
+ { array: FieldValue.arrayUnion(1, 2) },
+ { merge: true }
+ );
+
+ // Document will be cached but we'll be missing 42.
+ const snapshot = await docRef.get({ source: 'cache' });
+ expect(snapshot.data()).to.deep.equal({ array: [1, 2] });
+ });
+ });
+
+ it('update() with cached base doc using arrayUnion()', async () => {
+ await withTestDoc(persistence, async docRef => {
+ await docRef.set({ array: [42] });
+ await docRef.update({ array: FieldValue.arrayUnion(1, 2) });
+ const snapshot = await docRef.get({ source: 'cache' });
+ expect(snapshot.data()).to.deep.equal({ array: [42, 1, 2] });
+ });
+ });
+
+ it('update() with cached base doc using arrayRemove()', async () => {
+ await withTestDoc(persistence, async docRef => {
+ await docRef.set({ array: [42, 1, 2] });
+ await docRef.update({ array: FieldValue.arrayRemove(1, 2) });
+ const snapshot = await docRef.get({ source: 'cache' });
+ expect(snapshot.data()).to.deep.equal({ array: [42] });
+ });
+ });
+ });
+});
diff --git a/packages/firestore-compat/test/batch_writes.test.ts b/packages/firestore-compat/test/batch_writes.test.ts
new file mode 100644
index 00000000000..9c6a714b60f
--- /dev/null
+++ b/packages/firestore-compat/test/batch_writes.test.ts
@@ -0,0 +1,378 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as firestore from '@firebase/firestore-types';
+import { expect } from 'chai';
+
+import { EventsAccumulator } from './util/events_accumulator';
+import * as firebaseExport from './util/firebase_export';
+import * as integrationHelpers from './util/helpers';
+
+const apiDescribe = integrationHelpers.apiDescribe;
+const FieldPath = firebaseExport.FieldPath;
+const FieldValue = firebaseExport.FieldValue;
+const Timestamp = firebaseExport.Timestamp;
+
+apiDescribe('Database batch writes', (persistence: boolean) => {
+ it('supports empty batches', () => {
+ return integrationHelpers.withTestDb(persistence, db => {
+ return db.batch().commit();
+ });
+ });
+
+ it('can set documents', () => {
+ return integrationHelpers.withTestDoc(persistence, doc => {
+ return doc.firestore
+ .batch()
+ .set(doc, { foo: 'bar' })
+ .commit()
+ .then(() => doc.get())
+ .then(snapshot => {
+ expect(snapshot.exists).to.equal(true);
+ expect(snapshot.data()).to.deep.equal({ foo: 'bar' });
+ });
+ });
+ });
+
+ it('can set documents with merge', () => {
+ return integrationHelpers.withTestDoc(persistence, doc => {
+ return doc.firestore
+ .batch()
+ .set(doc, { a: 'b', nested: { a: 'b' } }, { merge: true })
+ .commit()
+ .then(() => {
+ return doc.firestore
+ .batch()
+ .set(doc, { c: 'd', nested: { c: 'd' } }, { merge: true })
+ .commit();
+ })
+ .then(() => doc.get())
+ .then(snapshot => {
+ expect(snapshot.exists).to.equal(true);
+ expect(snapshot.data()).to.deep.equal({
+ a: 'b',
+ c: 'd',
+ nested: { a: 'b', c: 'd' }
+ });
+ });
+ });
+ });
+
+ it('can update documents', () => {
+ return integrationHelpers.withTestDoc(persistence, doc => {
+ return doc
+ .set({ foo: 'bar' })
+ .then(() => doc.firestore.batch().update(doc, { baz: 42 }).commit())
+ .then(() => doc.get())
+ .then(snapshot => {
+ expect(snapshot.exists).to.equal(true);
+ expect(snapshot.data()).to.deep.equal({ foo: 'bar', baz: 42 });
+ });
+ });
+ });
+
+ it('can update nested fields', () => {
+ const initialData = {
+ desc: 'Description',
+ owner: { name: 'Jonny' },
+ 'is.admin': false
+ };
+ const finalData = {
+ desc: 'Description',
+ owner: { name: 'Sebastian' },
+ 'is.admin': true
+ };
+
+ return integrationHelpers.withTestDb(persistence, db => {
+ const doc = db.collection('counters').doc();
+ return doc.firestore
+ .batch()
+ .set(doc, initialData)
+ .update(doc, 'owner.name', 'Sebastian', new FieldPath('is.admin'), true)
+ .commit()
+ .then(() => doc.get())
+ .then(docSnapshot => {
+ expect(docSnapshot.exists).to.be.ok;
+ expect(docSnapshot.data()).to.deep.equal(finalData);
+ });
+ });
+ });
+
+ it('can delete documents', () => {
+ // TODO(#1865): This test fails with node:persistence against Prod
+ return integrationHelpers.withTestDoc(persistence, doc => {
+ return doc
+ .set({ foo: 'bar' })
+ .then(() => doc.get())
+ .then(snapshot => {
+ expect(snapshot.exists).to.equal(true);
+ })
+ .then(() => doc.firestore.batch().delete(doc).commit())
+ .then(() => doc.get())
+ .then(snapshot => {
+ expect(snapshot.exists).to.equal(false);
+ });
+ });
+ });
+
+ it('commit atomically, raising correct events', () => {
+ return integrationHelpers.withTestCollection(
+ persistence,
+ {},
+ collection => {
+ const docA = collection.doc('a');
+ const docB = collection.doc('b');
+ const accumulator = new EventsAccumulator();
+ const unsubscribe = collection.onSnapshot(
+ { includeMetadataChanges: true },
+ accumulator.storeEvent
+ );
+ return accumulator
+ .awaitEvent()
+ .then(initialSnap => {
+ expect(initialSnap.docs.length).to.equal(0);
+
+ // Atomically write two documents.
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ collection.firestore
+ .batch()
+ .set(docA, { a: 1 })
+ .set(docB, { b: 2 })
+ .commit();
+
+ return accumulator.awaitEvent();
+ })
+ .then(localSnap => {
+ expect(localSnap.metadata.hasPendingWrites).to.equal(true);
+ expect(integrationHelpers.toDataArray(localSnap)).to.deep.equal([
+ { a: 1 },
+ { b: 2 }
+ ]);
+ return accumulator.awaitEvent();
+ })
+ .then(serverSnap => {
+ expect(serverSnap.metadata.hasPendingWrites).to.equal(false);
+ expect(integrationHelpers.toDataArray(serverSnap)).to.deep.equal([
+ { a: 1 },
+ { b: 2 }
+ ]);
+ unsubscribe();
+ });
+ }
+ );
+ });
+
+ it('fail atomically, raising correct events', () => {
+ return integrationHelpers.withTestCollection(
+ persistence,
+ {},
+ collection => {
+ const docA = collection.doc('a');
+ const docB = collection.doc('b');
+ const accumulator = new EventsAccumulator();
+ const unsubscribe = collection.onSnapshot(
+ { includeMetadataChanges: true },
+ accumulator.storeEvent
+ );
+ let batchCommitPromise: Promise;
+ return accumulator
+ .awaitEvent()
+ .then(initialSnap => {
+ expect(initialSnap.docs.length).to.equal(0);
+
+ // Atomically write 1 document and update a nonexistent
+ // document.
+ batchCommitPromise = collection.firestore
+ .batch()
+ .set(docA, { a: 1 })
+ .update(docB, { b: 2 })
+ .commit();
+
+ // Node logs warnings if you don't attach an error handler to a
+ // Promise before it fails, so attach a dummy one here (we handle
+ // the rejection for real below).
+ batchCommitPromise.catch(err => {});
+
+ return accumulator.awaitEvent();
+ })
+ .then(localSnap => {
+ // Local event with the set document.
+ expect(localSnap.metadata.hasPendingWrites).to.equal(true);
+ expect(integrationHelpers.toDataArray(localSnap)).to.deep.equal([
+ { a: 1 }
+ ]);
+
+ return accumulator.awaitEvent();
+ })
+ .then(serverSnap => {
+ // Server event with the set reverted.
+ expect(serverSnap.metadata.hasPendingWrites).to.equal(false);
+ expect(serverSnap.docs.length).to.equal(0);
+
+ return batchCommitPromise;
+ })
+ .then(
+ () => {
+ expect.fail('Batch commit should have failed.');
+ },
+ err => {
+ expect(err.message).to.exist;
+ expect(err.code).to.equal('not-found');
+ unsubscribe();
+ }
+ );
+ }
+ );
+ });
+
+ it('write the same server timestamp across writes', () => {
+ return integrationHelpers.withTestCollection(
+ persistence,
+ {},
+ collection => {
+ const docA = collection.doc('a');
+ const docB = collection.doc('b');
+ const accumulator = new EventsAccumulator();
+ const unsubscribe = collection.onSnapshot(
+ { includeMetadataChanges: true },
+ accumulator.storeEvent
+ );
+ return accumulator
+ .awaitEvent()
+ .then(initialSnap => {
+ expect(initialSnap.docs.length).to.equal(0);
+
+ // Atomically write 2 documents with server timestamps.
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ collection.firestore
+ .batch()
+ .set(docA, {
+ when: FieldValue.serverTimestamp()
+ })
+ .set(docB, {
+ when: FieldValue.serverTimestamp()
+ })
+ .commit();
+
+ return accumulator.awaitEvent();
+ })
+ .then(localSnap => {
+ expect(localSnap.metadata.hasPendingWrites).to.equal(true);
+ expect(localSnap.docs.length).to.equal(2);
+ expect(integrationHelpers.toDataArray(localSnap)).to.deep.equal([
+ { when: null },
+ { when: null }
+ ]);
+
+ return accumulator.awaitEvent();
+ })
+ .then(serverSnap => {
+ expect(serverSnap.metadata.hasPendingWrites).to.equal(false);
+ expect(serverSnap.docs.length).to.equal(2);
+ const when = serverSnap.docs[0].data()['when'];
+ expect(when).to.be.an.instanceof(Timestamp);
+ expect(serverSnap.docs[1].data()['when']).to.deep.equal(when);
+ const docChanges = serverSnap.docChanges({
+ includeMetadataChanges: true
+ });
+ expect(docChanges[0].type).to.equal('modified');
+ unsubscribe();
+ });
+ }
+ );
+ });
+
+ it('can write the same document multiple times', () => {
+ return integrationHelpers.withTestDoc(persistence, doc => {
+ const accumulator = new EventsAccumulator();
+ const unsubscribe = doc.onSnapshot(
+ { includeMetadataChanges: true },
+ accumulator.storeEvent
+ );
+ return accumulator
+ .awaitEvent()
+ .then(initialSnap => {
+ expect(initialSnap.exists).to.equal(false);
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ doc.firestore
+ .batch()
+ .delete(doc)
+ .set(doc, { a: 1, b: 1, when: 'when' })
+ .update(doc, {
+ b: 2,
+ when: FieldValue.serverTimestamp()
+ })
+ .commit();
+
+ return accumulator.awaitEvent();
+ })
+ .then(localSnap => {
+ expect(localSnap.metadata.hasPendingWrites).to.equal(true);
+ expect(localSnap.data()).to.deep.equal({ a: 1, b: 2, when: null });
+
+ return accumulator.awaitEvent();
+ })
+ .then(serverSnap => {
+ expect(serverSnap.metadata.hasPendingWrites).to.equal(false);
+ const when = serverSnap.get('when');
+ expect(when).to.be.an.instanceof(Timestamp);
+ expect(serverSnap.data()).to.deep.equal({ a: 1, b: 2, when });
+ unsubscribe();
+ });
+ });
+ });
+
+ // PORTING NOTE: These tests are for FirestoreDataConverter support and apply
+ // only to web.
+ apiDescribe('withConverter() support', (persistence: boolean) => {
+ class Post {
+ constructor(readonly title: string, readonly author: string) {}
+ byline(): string {
+ return this.title + ', by ' + this.author;
+ }
+ }
+
+ it('for Writebatch.set()', () => {
+ return integrationHelpers.withTestDb(persistence, db => {
+ const docRef = db
+ .collection('posts')
+ .doc()
+ .withConverter({
+ toFirestore(post: Post): firestore.DocumentData {
+ return { title: post.title, author: post.author };
+ },
+ fromFirestore(
+ snapshot: firestore.QueryDocumentSnapshot,
+ options: firestore.SnapshotOptions
+ ): Post {
+ const data = snapshot.data(options);
+ return new Post(data.title, data.author);
+ }
+ });
+ return docRef.firestore
+ .batch()
+ .set(docRef, new Post('post', 'author'))
+ .commit()
+ .then(() => docRef.get())
+ .then(snapshot => {
+ expect(snapshot.exists).to.equal(true);
+ expect(snapshot.data()!.byline()).to.deep.equal('post, by author');
+ });
+ });
+ });
+ });
+});
diff --git a/packages/firestore-compat/test/bootstrap.ts b/packages/firestore-compat/test/bootstrap.ts
new file mode 100644
index 00000000000..8a42158b69d
--- /dev/null
+++ b/packages/firestore-compat/test/bootstrap.ts
@@ -0,0 +1,31 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import '../src/index';
+
+/**
+ * This will include all of the test files and compile them as needed
+ *
+ * Taken from karma-webpack source:
+ * https://github.com/webpack-contrib/karma-webpack#alternative-usage
+ */
+
+// 'context()' definition requires additional dependency on webpack-env package.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+const testsContext = (require as any).context('.', true, /^(.)*\.test$/);
+const browserTests = testsContext.keys();
+browserTests.forEach(testsContext);
diff --git a/packages/firestore-compat/test/bundle.test.ts b/packages/firestore-compat/test/bundle.test.ts
new file mode 100644
index 00000000000..ad1d730fedf
--- /dev/null
+++ b/packages/firestore-compat/test/bundle.test.ts
@@ -0,0 +1,252 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as firestore from '@firebase/firestore-types';
+import { expect } from 'chai';
+
+import { EventsAccumulator } from './util/events_accumulator';
+import {
+ apiDescribe,
+ toDataArray,
+ withAlternateTestDb,
+ withTestDb
+} from './util/helpers';
+
+export const encoder = new TextEncoder();
+
+function verifySuccessProgress(p: firestore.LoadBundleTaskProgress): void {
+ expect(p.taskState).to.equal('Success');
+ expect(p.bytesLoaded).to.be.equal(p.totalBytes);
+ expect(p.documentsLoaded).to.equal(p.totalDocuments);
+}
+
+function verifyInProgress(
+ p: firestore.LoadBundleTaskProgress,
+ expectedDocuments: number
+): void {
+ expect(p.taskState).to.equal('Running');
+ expect(p.bytesLoaded <= p.totalBytes).to.be.true;
+ expect(p.documentsLoaded <= p.totalDocuments).to.be.true;
+ expect(p.documentsLoaded).to.equal(expectedDocuments);
+}
+
+// This template is generated from bundleWithTestDocsAndQueries in '../util/internal_helpsers.ts',
+// and manually copied here.
+const BUNDLE_TEMPLATE = [
+ '{"metadata":{"id":"test-bundle","createTime":{"seconds":1001,"nanos":9999},"version":1,"totalDocuments":2,"totalBytes":1503}}',
+ '{"namedQuery":{"name":"limit","readTime":{"seconds":1000,"nanos":9999},"bundledQuery":{"parent":"projects/{0}/databases/(default)/documents","structuredQuery":{"from":[{"collectionId":"coll-1"}],"orderBy":[{"field":{"fieldPath":"bar"},"direction":"DESCENDING"},{"field":{"fieldPath":"__name__"},"direction":"DESCENDING"}],"limit":{"value":1}},"limitType":"FIRST"}}}',
+ '{"namedQuery":{"name":"limit-to-last","readTime":{"seconds":1000,"nanos":9999},"bundledQuery":{"parent":"projects/{0}/databases/(default)/documents","structuredQuery":{"from":[{"collectionId":"coll-1"}],"orderBy":[{"field":{"fieldPath":"bar"},"direction":"DESCENDING"},{"field":{"fieldPath":"__name__"},"direction":"DESCENDING"}],"limit":{"value":1}},"limitType":"LAST"}}}',
+ '{"documentMetadata":{"name":"projects/{0}/databases/(default)/documents/coll-1/a","readTime":{"seconds":1000,"nanos":9999},"exists":true}}',
+ '{"document":{"name":"projects/{0}/databases/(default)/documents/coll-1/a","createTime":{"seconds":1,"nanos":9},"updateTime":{"seconds":1,"nanos":9},"fields":{"k":{"stringValue":"a"},"bar":{"integerValue":1}}}}',
+ '{"documentMetadata":{"name":"projects/{0}/databases/(default)/documents/coll-1/b","readTime":{"seconds":1000,"nanos":9999},"exists":true}}',
+ '{"document":{"name":"projects/{0}/databases/(default)/documents/coll-1/b","createTime":{"seconds":1,"nanos":9},"updateTime":{"seconds":1,"nanos":9},"fields":{"k":{"stringValue":"b"},"bar":{"integerValue":2}}}}'
+];
+
+apiDescribe('Bundles', (persistence: boolean) => {
+ function verifySnapEqualsTestDocs(snap: firestore.QuerySnapshot): void {
+ expect(toDataArray(snap)).to.deep.equal([
+ { k: 'a', bar: 1 },
+ { k: 'b', bar: 2 }
+ ]);
+ }
+
+ /**
+ * Returns a valid bundle string from replacing project id in `BUNDLE_TEMPLATE` with the given
+ * db project id (also recalculate length prefixes).
+ */
+ function bundleString(db: firestore.FirebaseFirestore): string {
+ const projectId: string = db.app.options.projectId;
+
+ // Extract elements from BUNDLE_TEMPLATE and replace the project ID.
+ const elements = BUNDLE_TEMPLATE.map(e => e.replace('{0}', projectId));
+
+ // Recalculating length prefixes for elements that are not BundleMetadata.
+ let bundleContent = '';
+ for (const element of elements.slice(1)) {
+ const length = encoder.encode(element).byteLength;
+ bundleContent += `${length}${element}`;
+ }
+
+ // Update BundleMetadata with new totalBytes.
+ const totalBytes = encoder.encode(bundleContent).byteLength;
+ const metadata = JSON.parse(elements[0]);
+ metadata.metadata.totalBytes = totalBytes;
+ const metadataContent = JSON.stringify(metadata);
+ const metadataLength = encoder.encode(metadataContent).byteLength;
+ return `${metadataLength}${metadataContent}${bundleContent}`;
+ }
+
+ it('load with documents only with on progress and promise interface', () => {
+ return withTestDb(persistence, async db => {
+ const progressEvents: firestore.LoadBundleTaskProgress[] = [];
+ let completeCalled = false;
+ const task: firestore.LoadBundleTask = db.loadBundle(bundleString(db));
+ task.onProgress(
+ progress => {
+ progressEvents.push(progress);
+ },
+ undefined,
+ () => {
+ completeCalled = true;
+ }
+ );
+ await task;
+ let fulfillProgress: firestore.LoadBundleTaskProgress;
+ await task.then(progress => {
+ fulfillProgress = progress;
+ });
+
+ expect(completeCalled).to.be.true;
+ expect(progressEvents.length).to.equal(4);
+ verifyInProgress(progressEvents[0], 0);
+ verifyInProgress(progressEvents[1], 1);
+ verifyInProgress(progressEvents[2], 2);
+ verifySuccessProgress(progressEvents[3]);
+ expect(fulfillProgress!).to.deep.equal(progressEvents[3]);
+
+ // Read from cache. These documents do not exist in backend, so they can
+ // only be read from cache.
+ let snap = await db.collection('coll-1').get({ source: 'cache' });
+ verifySnapEqualsTestDocs(snap);
+
+ snap = await (await db.namedQuery('limit'))!.get({
+ source: 'cache'
+ });
+ expect(toDataArray(snap)).to.deep.equal([{ k: 'b', bar: 2 }]);
+
+ snap = await (await db.namedQuery('limit-to-last'))!.get({
+ source: 'cache'
+ });
+ expect(toDataArray(snap)).to.deep.equal([{ k: 'a', bar: 1 }]);
+ });
+ });
+
+ it('load with documents and queries with promise interface', () => {
+ return withTestDb(persistence, async db => {
+ const fulfillProgress: firestore.LoadBundleTaskProgress =
+ await db.loadBundle(bundleString(db));
+
+ verifySuccessProgress(fulfillProgress!);
+
+ // Read from cache. These documents do not exist in backend, so they can
+ // only be read from cache.
+ const snap = await db.collection('coll-1').get({ source: 'cache' });
+ verifySnapEqualsTestDocs(snap);
+ });
+ });
+
+ it('load for a second time skips', () => {
+ return withTestDb(persistence, async db => {
+ await db.loadBundle(bundleString(db));
+
+ let completeCalled = false;
+ const progressEvents: firestore.LoadBundleTaskProgress[] = [];
+ const task: firestore.LoadBundleTask = db.loadBundle(
+ encoder.encode(bundleString(db))
+ );
+ task.onProgress(
+ progress => {
+ progressEvents.push(progress);
+ },
+ error => {},
+ () => {
+ completeCalled = true;
+ }
+ );
+ await task;
+
+ expect(completeCalled).to.be.true;
+ // No loading actually happened in the second `loadBundle` call only the
+ // success progress is recorded.
+ expect(progressEvents.length).to.equal(1);
+ verifySuccessProgress(progressEvents[0]);
+
+ // Read from cache. These documents do not exist in backend, so they can
+ // only be read from cache.
+ const snap = await db.collection('coll-1').get({ source: 'cache' });
+ verifySnapEqualsTestDocs(snap);
+ });
+ });
+
+ it('load with documents already pulled from backend', () => {
+ return withTestDb(persistence, async db => {
+ await db.doc('coll-1/a').set({ k: 'a', bar: 0 });
+ await db.doc('coll-1/b').set({ k: 'b', bar: 0 });
+
+ const accumulator = new EventsAccumulator();
+ db.collection('coll-1').onSnapshot(accumulator.storeEvent);
+ await accumulator.awaitEvent();
+
+ const progress = await db.loadBundle(
+ // Testing passing in non-string bundles.
+ encoder.encode(bundleString(db))
+ );
+
+ verifySuccessProgress(progress);
+ // The test bundle is holding ancient documents, so no events are
+ // generated as a result. The case where a bundle has newer doc than
+ // cache can only be tested in spec tests.
+ await accumulator.assertNoAdditionalEvents();
+
+ let snap = await (await db.namedQuery('limit'))!.get();
+ expect(toDataArray(snap)).to.deep.equal([{ k: 'b', bar: 0 }]);
+
+ snap = await (await db.namedQuery('limit-to-last'))!.get();
+ expect(toDataArray(snap)).to.deep.equal([{ k: 'a', bar: 0 }]);
+ });
+ });
+
+ it('loaded documents should not be GC-ed right away', () => {
+ return withTestDb(persistence, async db => {
+ const fulfillProgress: firestore.LoadBundleTaskProgress =
+ await db.loadBundle(bundleString(db));
+
+ verifySuccessProgress(fulfillProgress!);
+
+ // Read a different collection, this will trigger GC.
+ let snap = await db.collection('coll-other').get();
+ expect(snap.empty).to.be.true;
+
+ // Read the loaded documents, expecting document in cache. With memory
+ // GC, the documents would get GC-ed if we did not hold the document keys
+ // in a "umbrella" target. See local_store.ts for details.
+ snap = await db.collection('coll-1').get({ source: 'cache' });
+ verifySnapEqualsTestDocs(snap);
+ });
+ });
+
+ it('load with documents from other projects fails', () => {
+ return withTestDb(persistence, async db => {
+ return withAlternateTestDb(persistence, async otherDb => {
+ await expect(otherDb.loadBundle(bundleString(db))).to.be.rejectedWith(
+ 'Tried to deserialize key from different project'
+ );
+
+ // Verify otherDb still functions, despite loaded a problematic bundle.
+ const finalProgress = await otherDb.loadBundle(bundleString(otherDb));
+ verifySuccessProgress(finalProgress);
+
+ // Read from cache. These documents do not exist in backend, so they can
+ // only be read from cache.
+ const snap = await otherDb
+ .collection('coll-1')
+ .get({ source: 'cache' });
+ verifySnapEqualsTestDocs(snap);
+ });
+ });
+ });
+});
diff --git a/packages/firestore-compat/test/cursor.test.ts b/packages/firestore-compat/test/cursor.test.ts
new file mode 100644
index 00000000000..a9a93c9ba51
--- /dev/null
+++ b/packages/firestore-compat/test/cursor.test.ts
@@ -0,0 +1,312 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { Timestamp as TimestampInstance } from '@firebase/firestore-types';
+import { expect } from 'chai';
+
+import * as firebaseExport from './util/firebase_export';
+import {
+ apiDescribe,
+ toDataArray,
+ toIds,
+ withTestCollection,
+ withTestDb,
+ withTestDbs
+} from './util/helpers';
+
+const Timestamp = firebaseExport.Timestamp;
+const FieldPath = firebaseExport.FieldPath;
+
+apiDescribe('Cursors', (persistence: boolean) => {
+ it('can page through items', () => {
+ const testDocs = {
+ a: { v: 'a' },
+ b: { v: 'b' },
+ c: { v: 'c' },
+ d: { v: 'd' },
+ e: { v: 'e' },
+ f: { v: 'f' }
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ return coll
+ .limit(2)
+ .get()
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([{ v: 'a' }, { v: 'b' }]);
+ const lastDoc = docs.docs[docs.docs.length - 1];
+ return coll.limit(3).startAfter(lastDoc).get();
+ })
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([
+ { v: 'c' },
+ { v: 'd' },
+ { v: 'e' }
+ ]);
+ const lastDoc = docs.docs[docs.docs.length - 1];
+ return coll.limit(1).startAfter(lastDoc).get();
+ })
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([{ v: 'f' }]);
+ const lastDoc = docs.docs[docs.docs.length - 1];
+ return coll.limit(3).startAfter(lastDoc).get();
+ })
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([]);
+ });
+ });
+ });
+
+ it('can be created from documents', () => {
+ const testDocs = {
+ a: { k: 'a', sort: 1 },
+ b: { k: 'b', sort: 2 },
+ c: { k: 'c', sort: 2 },
+ d: { k: 'd', sort: 2 },
+ e: { k: 'e', sort: 0 },
+ f: { k: 'f', nosort: 1 } // should not show up
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ const query = coll.orderBy('sort');
+ return coll
+ .doc('c')
+ .get()
+ .then(doc => {
+ expect(doc.data()).to.deep.equal({ k: 'c', sort: 2 });
+ return query
+ .startAt(doc)
+ .get()
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([
+ { k: 'c', sort: 2 },
+ { k: 'd', sort: 2 }
+ ]);
+ return query.endBefore(doc).get();
+ });
+ })
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([
+ { k: 'e', sort: 0 },
+ { k: 'a', sort: 1 },
+ { k: 'b', sort: 2 }
+ ]);
+ });
+ });
+ });
+
+ it('can be created from values', () => {
+ const testDocs = {
+ a: { k: 'a', sort: 1 },
+ b: { k: 'b', sort: 2 },
+ c: { k: 'c', sort: 2 },
+ d: { k: 'd', sort: 2 },
+ e: { k: 'e', sort: 0 },
+ f: { k: 'f', nosort: 1 } // should not show up
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ const query = coll.orderBy('sort');
+ return query
+ .startAt(2)
+ .get()
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([
+ { k: 'b', sort: 2 },
+ { k: 'c', sort: 2 },
+ { k: 'd', sort: 2 }
+ ]);
+ return query.endBefore(2).get();
+ })
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([
+ { k: 'e', sort: 0 },
+ { k: 'a', sort: 1 }
+ ]);
+ });
+ });
+ });
+
+ it('can be created using document id', () => {
+ const testDocs: { [key: string]: {} } = {
+ a: { k: 'a' },
+ b: { k: 'b' },
+ c: { k: 'c' },
+ d: { k: 'd' },
+ e: { k: 'e' }
+ };
+ return withTestDbs(persistence, 2, ([reader, writer]) => {
+ // Create random subcollection with documents pre-filled. Note that
+ // we use subcollections to test the relative nature of __id__.
+ const writerCollection = writer
+ .collection('parent-collection')
+ .doc()
+ .collection('sub-collection');
+ const readerCollection = reader.collection(writerCollection.path);
+ const sets: Array> = [];
+ Object.keys(testDocs).forEach((key: string) => {
+ sets.push(writerCollection.doc(key).set(testDocs[key]));
+ });
+
+ return Promise.all(sets)
+ .then(() => {
+ return readerCollection
+ .orderBy(FieldPath.documentId())
+ .startAt('b')
+ .endBefore('d')
+ .get();
+ })
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([{ k: 'b' }, { k: 'c' }]);
+ });
+ });
+ });
+
+ it('can be used with reference values', () => {
+ // We require a db to create reference values
+ return withTestDb(persistence, db => {
+ const testDocs = {
+ a: { k: '1a', ref: db.collection('1').doc('a') },
+ b: { k: '1b', ref: db.collection('1').doc('b') },
+ c: { k: '2a', ref: db.collection('2').doc('a') },
+ d: { k: '2b', ref: db.collection('2').doc('b') },
+ e: { k: '3a', ref: db.collection('3').doc('a') }
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ const query = coll.orderBy('ref');
+ return query
+ .startAfter(db.collection('1').doc('a'))
+ .endAt(db.collection('2').doc('b'))
+ .get()
+ .then(docs => {
+ expect(toDataArray(docs).map(v => v['k'])).to.deep.equal([
+ '1b',
+ '2a',
+ '2b'
+ ]);
+ });
+ });
+ });
+ });
+
+ it('can be used in descending queries', () => {
+ const testDocs = {
+ a: { k: 'a', sort: 1 },
+ b: { k: 'b', sort: 2 },
+ c: { k: 'c', sort: 2 },
+ d: { k: 'd', sort: 3 },
+ e: { k: 'e', sort: 0 },
+ f: { k: 'f', nosort: 1 } // should not show up
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ const query = coll
+ .orderBy('sort', 'desc')
+ // default indexes reverse the key ordering for descending sorts
+ .orderBy(FieldPath.documentId(), 'desc');
+ return query
+ .startAt(2)
+ .get()
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([
+ { k: 'c', sort: 2 },
+ { k: 'b', sort: 2 },
+ { k: 'a', sort: 1 },
+ { k: 'e', sort: 0 }
+ ]);
+ return query.endBefore(2).get();
+ })
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([{ k: 'd', sort: 3 }]);
+ });
+ });
+ });
+
+ // Currently, timestamps are truncated to microseconds on the backend, so
+ // don't create timestamps with more precision than that.
+ const makeTimestamp = (seconds: number, micros: number): TimestampInstance =>
+ new Timestamp(seconds, micros * 1000);
+
+ it('can accept Timestamps as bounds', () => {
+ const testDocs = {
+ a: { timestamp: makeTimestamp(100, 2) },
+ b: { timestamp: makeTimestamp(100, 5) },
+ c: { timestamp: makeTimestamp(100, 3) },
+ d: { timestamp: makeTimestamp(100, 1) },
+ // Number of microseconds deliberately repeated.
+ e: { timestamp: makeTimestamp(100, 5) },
+ f: { timestamp: makeTimestamp(100, 4) }
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ return coll
+ .orderBy('timestamp')
+ .startAfter(makeTimestamp(100, 2))
+ .endAt(makeTimestamp(100, 5))
+ .get()
+ .then(docs => {
+ expect(toIds(docs)).to.deep.equal(['c', 'f', 'b', 'e']);
+ });
+ });
+ });
+
+ it('can accept Timestamps in where clause', () => {
+ const testDocs = {
+ a: { timestamp: makeTimestamp(100, 7) },
+ b: { timestamp: makeTimestamp(100, 4) },
+ c: { timestamp: makeTimestamp(100, 8) },
+ d: { timestamp: makeTimestamp(100, 5) },
+ e: { timestamp: makeTimestamp(100, 6) }
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ return coll
+ .where('timestamp', '>=', makeTimestamp(100, 5))
+ .where('timestamp', '<', makeTimestamp(100, 8))
+ .get()
+ .then(docs => {
+ expect(toIds(docs)).to.deep.equal(['d', 'e', 'a']);
+ });
+ });
+ });
+
+ it('truncate Timestamps', () => {
+ const nanos = new Timestamp(0, 123456789);
+ const micros = new Timestamp(0, 123456000);
+ const millis = new Timestamp(0, 123000000);
+
+ const testDocs = {
+ a: { timestamp: nanos }
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ return coll
+ .where('timestamp', '==', nanos)
+ .get()
+ .then(docs => {
+ expect(toIds(docs)).to.deep.equal(['a']);
+ return coll.where('timestamp', '==', micros).get();
+ })
+ .then(docs => {
+ // Because Timestamp should have been truncated to microseconds, the
+ // microsecond timestamp should be considered equal to the
+ // nanosecond one.
+ expect(toIds(docs)).to.deep.equal(['a']);
+ return coll.where('timestamp', '==', millis).get();
+ })
+ .then(docs => {
+ // The truncation is just to the microseconds, however, so the
+ // millisecond timestamp should be treated as different and thus the
+ // query should return no results.
+ expect(toIds(docs)).to.be.empty;
+ });
+ });
+ });
+});
diff --git a/packages/firestore-compat/test/database.test.ts b/packages/firestore-compat/test/database.test.ts
new file mode 100644
index 00000000000..69237d74897
--- /dev/null
+++ b/packages/firestore-compat/test/database.test.ts
@@ -0,0 +1,1690 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as firestore from '@firebase/firestore-types';
+import { Deferred } from '@firebase/util';
+import { expect, use } from 'chai';
+import chaiAsPromised from 'chai-as-promised';
+
+import { EventsAccumulator } from './util/events_accumulator';
+import * as firebaseExport from './util/firebase_export';
+import {
+ apiDescribe,
+ withTestCollection,
+ withTestDbsSettings,
+ withTestDb,
+ withTestDbs,
+ withTestDoc,
+ withTestDocAndInitialData
+} from './util/helpers';
+import { DEFAULT_SETTINGS, DEFAULT_PROJECT_ID } from './util/settings';
+
+use(chaiAsPromised);
+
+const newTestFirestore = firebaseExport.newTestFirestore;
+const Timestamp = firebaseExport.Timestamp;
+const FieldPath = firebaseExport.FieldPath;
+const FieldValue = firebaseExport.FieldValue;
+const DocumentReference = firebaseExport.DocumentReference;
+const QueryDocumentSnapshot = firebaseExport.QueryDocumentSnapshot;
+
+apiDescribe('Database', (persistence: boolean) => {
+ it('can set a document', () => {
+ return withTestDoc(persistence, docRef => {
+ return docRef.set({
+ desc: 'Stuff related to Firestore project...',
+ owner: {
+ name: 'Jonny',
+ title: 'scallywag'
+ }
+ });
+ });
+ });
+
+ it('doc() will auto generate an ID', () => {
+ return withTestDb(persistence, async db => {
+ const ref = db.collection('foo').doc();
+ // Auto IDs are 20 characters long
+ expect(ref.id.length).to.equal(20);
+ });
+ });
+
+ it('can delete a document', () => {
+ // TODO(#1865): This test fails with node:persistence against Prod
+ return withTestDoc(persistence, docRef => {
+ return docRef
+ .set({ foo: 'bar' })
+ .then(() => {
+ return docRef.get();
+ })
+ .then(doc => {
+ expect(doc.data()).to.deep.equal({ foo: 'bar' });
+ return docRef.delete();
+ })
+ .then(() => {
+ return docRef.get();
+ })
+ .then(doc => {
+ expect(doc.exists).to.equal(false);
+ });
+ });
+ });
+
+ it('can update existing document', () => {
+ return withTestDoc(persistence, doc => {
+ const initialData = {
+ desc: 'Description',
+ owner: { name: 'Jonny', email: 'abc@xyz.com' }
+ };
+ const updateData = {
+ desc: 'NewDescription',
+ 'owner.email': 'new@xyz.com'
+ };
+ const finalData = {
+ desc: 'NewDescription',
+ owner: { name: 'Jonny', email: 'new@xyz.com' }
+ };
+ return doc
+ .set(initialData)
+ .then(() => doc.update(updateData))
+ .then(() => doc.get())
+ .then(docSnapshot => {
+ expect(docSnapshot.exists).to.be.ok;
+ expect(docSnapshot.data()).to.deep.equal(finalData);
+ });
+ });
+ });
+
+ it('can retrieve document that does not exist', () => {
+ return withTestDoc(persistence, doc => {
+ return doc.get().then(snapshot => {
+ expect(snapshot.exists).to.equal(false);
+ expect(snapshot.data()).to.equal(undefined);
+ expect(snapshot.get('foo')).to.equal(undefined);
+ });
+ });
+ });
+
+ // eslint-disable-next-line no-restricted-properties
+ (persistence ? it : it.skip)('can update an unknown document', () => {
+ return withTestDbs(persistence, 2, async ([reader, writer]) => {
+ const writerRef = writer.collection('collection').doc();
+ const readerRef = reader.collection('collection').doc(writerRef.id);
+ await writerRef.set({ a: 'a' });
+ await readerRef.update({ b: 'b' });
+ await writerRef
+ .get({ source: 'cache' })
+ .then(doc => expect(doc.exists).to.be.true);
+ await readerRef.get({ source: 'cache' }).then(
+ () => {
+ expect.fail('Expected cache miss');
+ },
+ err => expect(err.code).to.be.equal('unavailable')
+ );
+ await writerRef
+ .get()
+ .then(doc => expect(doc.data()).to.deep.equal({ a: 'a', b: 'b' }));
+ await readerRef
+ .get()
+ .then(doc => expect(doc.data()).to.deep.equal({ a: 'a', b: 'b' }));
+ });
+ });
+
+ it('can merge data with an existing document using set', () => {
+ return withTestDoc(persistence, doc => {
+ const initialData = {
+ desc: 'description',
+ 'owner.data': { name: 'Jonny', email: 'abc@xyz.com' }
+ };
+ const mergeData = {
+ updated: true,
+ 'owner.data': { name: 'Sebastian' }
+ };
+ const finalData = {
+ updated: true,
+ desc: 'description',
+ 'owner.data': { name: 'Sebastian', email: 'abc@xyz.com' }
+ };
+ return doc
+ .set(initialData)
+ .then(() => doc.set(mergeData, { merge: true }))
+ .then(() => doc.get())
+ .then(docSnapshot => {
+ expect(docSnapshot.exists).to.be.ok;
+ expect(docSnapshot.data()).to.deep.equal(finalData);
+ });
+ });
+ });
+
+ it('can merge server timestamps', () => {
+ return withTestDoc(persistence, doc => {
+ const initialData = {
+ updated: false
+ };
+ const mergeData = {
+ time: FieldValue.serverTimestamp(),
+ nested: { time: FieldValue.serverTimestamp() }
+ };
+ return doc
+ .set(initialData)
+ .then(() => doc.set(mergeData, { merge: true }))
+ .then(() => doc.get())
+ .then(docSnapshot => {
+ expect(docSnapshot.exists).to.be.ok;
+ expect(docSnapshot.get('updated')).to.be.false;
+ expect(docSnapshot.get('time')).to.be.an.instanceof(Timestamp);
+ expect(docSnapshot.get('nested.time')).to.be.an.instanceof(Timestamp);
+ });
+ });
+ });
+
+ it('can merge empty object', async () => {
+ await withTestDoc(persistence, async doc => {
+ const accumulator = new EventsAccumulator();
+ const unsubscribe = doc.onSnapshot(accumulator.storeEvent);
+ await accumulator
+ .awaitEvent()
+ .then(() => doc.set({}))
+ .then(() => accumulator.awaitEvent())
+ .then(docSnapshot => expect(docSnapshot.data()).to.be.deep.equal({}))
+ .then(() => doc.set({ a: {} }, { mergeFields: ['a'] }))
+ .then(() => accumulator.awaitEvent())
+ .then(docSnapshot =>
+ expect(docSnapshot.data()).to.be.deep.equal({ a: {} })
+ )
+ .then(() => doc.set({ b: {} }, { merge: true }))
+ .then(() => accumulator.awaitEvent())
+ .then(docSnapshot =>
+ expect(docSnapshot.data()).to.be.deep.equal({ a: {}, b: {} })
+ )
+ .then(() => doc.get({ source: 'server' }))
+ .then(docSnapshot => {
+ expect(docSnapshot.data()).to.be.deep.equal({ a: {}, b: {} });
+ });
+
+ unsubscribe();
+ });
+ });
+
+ it('update with empty object replaces all fields', () => {
+ return withTestDoc(persistence, async doc => {
+ await doc.set({ a: 'a' });
+ await doc.update('a', {});
+ const docSnapshot = await doc.get();
+ expect(docSnapshot.data()).to.be.deep.equal({ a: {} });
+ });
+ });
+
+ it('merge with empty object replaces all fields', () => {
+ return withTestDoc(persistence, async doc => {
+ await doc.set({ a: 'a' });
+ await doc.set({ 'a': {} }, { merge: true });
+ const docSnapshot = await doc.get();
+ expect(docSnapshot.data()).to.be.deep.equal({ a: {} });
+ });
+ });
+
+ it('can delete field using merge', () => {
+ return withTestDoc(persistence, doc => {
+ const initialData = {
+ untouched: true,
+ foo: 'bar',
+ nested: { untouched: true, foo: 'bar' }
+ };
+ const mergeData = {
+ foo: FieldValue.delete(),
+ nested: { foo: FieldValue.delete() }
+ };
+ const finalData = {
+ untouched: true,
+ nested: { untouched: true }
+ };
+ return doc
+ .set(initialData)
+ .then(() => doc.set(mergeData, { merge: true }))
+ .then(() => doc.get())
+ .then(docSnapshot => {
+ expect(docSnapshot.exists).to.be.ok;
+ expect(docSnapshot.data()).to.deep.equal(finalData);
+ });
+ });
+ });
+
+ it('can delete field using mergeFields', () => {
+ return withTestDoc(persistence, doc => {
+ const initialData = {
+ untouched: true,
+ foo: 'bar',
+ inner: { removed: true, foo: 'bar' },
+ nested: { untouched: true, foo: 'bar' }
+ };
+ const mergeData = {
+ foo: FieldValue.delete(),
+ inner: { foo: FieldValue.delete() },
+ nested: {
+ untouched: FieldValue.delete(),
+ foo: FieldValue.delete()
+ }
+ };
+ const finalData = {
+ untouched: true,
+ inner: {},
+ nested: { untouched: true }
+ };
+ return doc
+ .set(initialData)
+ .then(() =>
+ doc.set(mergeData, { mergeFields: ['foo', 'inner', 'nested.foo'] })
+ )
+ .then(() => doc.get())
+ .then(docSnapshot => {
+ expect(docSnapshot.exists).to.be.ok;
+ expect(docSnapshot.data()).to.deep.equal(finalData);
+ });
+ });
+ });
+
+ it('can set server timestamps using mergeFields', () => {
+ return withTestDoc(persistence, doc => {
+ const initialData = {
+ untouched: true,
+ foo: 'bar',
+ nested: { untouched: true, foo: 'bar' }
+ };
+ const mergeData = {
+ foo: FieldValue.serverTimestamp(),
+ inner: { foo: FieldValue.serverTimestamp() },
+ nested: { foo: FieldValue.serverTimestamp() }
+ };
+ return doc
+ .set(initialData)
+ .then(() =>
+ doc.set(mergeData, { mergeFields: ['foo', 'inner', 'nested.foo'] })
+ )
+ .then(() => doc.get())
+ .then(docSnapshot => {
+ expect(docSnapshot.exists).to.be.ok;
+ expect(docSnapshot.get('foo')).to.be.instanceof(Timestamp);
+ expect(docSnapshot.get('inner.foo')).to.be.instanceof(Timestamp);
+ expect(docSnapshot.get('nested.foo')).to.be.instanceof(Timestamp);
+ });
+ });
+ });
+
+ it('can replace an array by merging using set', () => {
+ return withTestDoc(persistence, doc => {
+ const initialData = {
+ untouched: true,
+ data: 'old',
+ topLevel: ['old', 'old'],
+ mapInArray: [{ data: 'old' }]
+ };
+ const mergeData = {
+ data: 'new',
+ topLevel: ['new'],
+ mapInArray: [{ data: 'new' }]
+ };
+ const finalData = {
+ untouched: true,
+ data: 'new',
+ topLevel: ['new'],
+ mapInArray: [{ data: 'new' }]
+ };
+ return doc
+ .set(initialData)
+ .then(() => doc.set(mergeData, { merge: true }))
+ .then(() => doc.get())
+ .then(docSnapshot => {
+ expect(docSnapshot.exists).to.be.ok;
+ expect(docSnapshot.data()).to.deep.equal(finalData);
+ });
+ });
+ });
+
+ it("can't specify a field mask for a missing field using set", () => {
+ return withTestDoc(persistence, async docRef => {
+ expect(() => {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ docRef.set(
+ { desc: 'NewDescription' },
+ { mergeFields: ['desc', 'owner'] }
+ );
+ }).to.throw(
+ "Field 'owner' is specified in your field mask but missing from your input data."
+ );
+ });
+ });
+
+ it('can set a subset of fields using a field mask', () => {
+ const initialData = {
+ desc: 'Description',
+ owner: { name: 'Jonny', email: 'abc@xyz.com' }
+ };
+ const finalData = { desc: 'Description', owner: 'Sebastian' };
+ return withTestDocAndInitialData(persistence, initialData, async docRef => {
+ await docRef.set(
+ { desc: 'NewDescription', owner: 'Sebastian' },
+ { mergeFields: ['owner'] }
+ );
+ const result = await docRef.get();
+ expect(result.data()).to.deep.equal(finalData);
+ });
+ });
+
+ it("doesn't apply field delete outside of mask", () => {
+ const initialData = {
+ desc: 'Description',
+ owner: { name: 'Jonny', email: 'abc@xyz.com' }
+ };
+ const finalData = { desc: 'Description', owner: 'Sebastian' };
+ return withTestDocAndInitialData(persistence, initialData, async docRef => {
+ await docRef.set(
+ { desc: FieldValue.delete(), owner: 'Sebastian' },
+ { mergeFields: ['owner'] }
+ );
+ const result = await docRef.get();
+ expect(result.data()).to.deep.equal(finalData);
+ });
+ });
+
+ it("doesn't apply field transform outside of mask", () => {
+ const initialData = {
+ desc: 'Description',
+ owner: { name: 'Jonny', email: 'abc@xyz.com' }
+ };
+ const finalData = { desc: 'Description', owner: 'Sebastian' };
+ return withTestDocAndInitialData(persistence, initialData, async docRef => {
+ await docRef.set(
+ {
+ desc: FieldValue.serverTimestamp(),
+ owner: 'Sebastian'
+ },
+ { mergeFields: ['owner'] }
+ );
+ const result = await docRef.get();
+ expect(result.data()).to.deep.equal(finalData);
+ });
+ });
+
+ it('can set an empty field mask', () => {
+ const initialData = {
+ desc: 'Description',
+ owner: { name: 'Jonny', email: 'abc@xyz.com' }
+ };
+ const finalData = initialData;
+ return withTestDocAndInitialData(persistence, initialData, async docRef => {
+ await docRef.set(
+ { desc: 'NewDescription', owner: 'Sebastian' },
+ { mergeFields: [] }
+ );
+ const result = await docRef.get();
+ expect(result.data()).to.deep.equal(finalData);
+ });
+ });
+
+ it('can specify fields multiple times in a field mask', () => {
+ const initialData = {
+ desc: 'Description',
+ owner: { name: 'Jonny', email: 'abc@xyz.com' }
+ };
+ const finalData = {
+ desc: 'Description',
+ owner: { name: 'Sebastian', email: 'new@xyz.com' }
+ };
+ return withTestDocAndInitialData(persistence, initialData, async docRef => {
+ await docRef.set(
+ {
+ desc: 'NewDescription',
+ owner: { name: 'Sebastian', email: 'new@xyz.com' }
+ },
+ { mergeFields: ['owner.name', 'owner', 'owner'] }
+ );
+ const result = await docRef.get();
+ expect(result.data()).to.deep.equal(finalData);
+ });
+ });
+
+ it('cannot update nonexistent document', () => {
+ return withTestDoc(persistence, doc => {
+ return doc
+ .update({ owner: 'abc' })
+ .then(
+ () => Promise.reject('update should have failed.'),
+ err => {
+ expect(err.message).to.exist;
+ expect(err.code).to.equal('not-found');
+ }
+ )
+ .then(() => doc.get())
+ .then(docSnapshot => {
+ expect(docSnapshot.exists).to.equal(false);
+ });
+ });
+ });
+
+ it('can delete a field with an update', () => {
+ return withTestDoc(persistence, doc => {
+ const initialData = {
+ desc: 'Description',
+ owner: { name: 'Jonny', email: 'abc@xyz.com' }
+ };
+ const updateData = {
+ 'owner.email': FieldValue.delete()
+ };
+ const finalData = {
+ desc: 'Description',
+ owner: { name: 'Jonny' }
+ };
+ return doc
+ .set(initialData)
+ .then(() => doc.update(updateData))
+ .then(() => doc.get())
+ .then(docSnapshot => {
+ expect(docSnapshot.exists).to.be.ok;
+ expect(docSnapshot.data()).to.deep.equal(finalData);
+ });
+ });
+ });
+
+ it('can update nested fields', () => {
+ return withTestDoc(persistence, doc => {
+ const initialData = {
+ desc: 'Description',
+ owner: { name: 'Jonny' },
+ 'is.admin': false
+ };
+ const finalData = {
+ desc: 'Description',
+ owner: { name: 'Sebastian' },
+ 'is.admin': true
+ };
+ return doc
+ .set(initialData)
+ .then(() =>
+ doc.update('owner.name', 'Sebastian', new FieldPath('is.admin'), true)
+ )
+ .then(() => doc.get())
+ .then(docSnapshot => {
+ expect(docSnapshot.exists).to.be.ok;
+ expect(docSnapshot.data()).to.deep.equal(finalData);
+ });
+ });
+ });
+
+ it('can specify updated field multiple times', () => {
+ return withTestDoc(persistence, doc => {
+ return doc
+ .set({})
+ .then(() => doc.update('field', 100, new FieldPath('field'), 200))
+ .then(() => doc.get())
+ .then(docSnap => {
+ expect(docSnap.data()).to.deep.equal({ field: 200 });
+ });
+ });
+ });
+
+ describe('documents: ', () => {
+ const invalidDocValues = [undefined, null, 0, 'foo', ['a'], new Date()];
+ for (const val of invalidDocValues) {
+ it('set/update should reject: ' + val, () => {
+ return withTestDoc(persistence, async doc => {
+ // Intentionally passing bad types.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ expect(() => doc.set(val as any)).to.throw();
+ // Intentionally passing bad types.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ expect(() => doc.update(val as any)).to.throw();
+ });
+ });
+ }
+ });
+
+ it('CollectionRef.add() resolves with resulting DocumentRef.', () => {
+ return withTestCollection(persistence, {}, coll => {
+ return coll
+ .add({ foo: 1 })
+ .then(docRef => docRef.get())
+ .then(docSnap => {
+ expect(docSnap.data()).to.deep.equal({ foo: 1 });
+ });
+ });
+ });
+
+ it('onSnapshotsInSync fires after listeners are in sync', () => {
+ const testDocs = {
+ a: { foo: 1 }
+ };
+ return withTestCollection(persistence, testDocs, async coll => {
+ let events: string[] = [];
+ const gotInitialSnapshot = new Deferred();
+ const doc = coll.doc('a');
+
+ doc.onSnapshot(snap => {
+ events.push('doc');
+ gotInitialSnapshot.resolve();
+ });
+ await gotInitialSnapshot.promise;
+ events = [];
+
+ const done = new Deferred();
+ doc.firestore.onSnapshotsInSync(() => {
+ events.push('snapshots-in-sync');
+ if (events.length === 3) {
+ // We should have an initial snapshots-in-sync event, then a snapshot
+ // event for set(), then another event to indicate we're in sync
+ // again.
+ expect(events).to.deep.equal([
+ 'snapshots-in-sync',
+ 'doc',
+ 'snapshots-in-sync'
+ ]);
+ done.resolve();
+ }
+ });
+
+ await doc.set({ foo: 3 });
+ await done.promise;
+ });
+ });
+
+ apiDescribe('Queries are validated client-side', (persistence: boolean) => {
+ // NOTE: Failure cases are validated in validation_test.ts
+
+ it('same inequality fields works', () => {
+ return withTestCollection(persistence, {}, async coll => {
+ expect(() =>
+ coll.where('x', '>=', 32).where('x', '<=', 'cat')
+ ).not.to.throw();
+ });
+ });
+
+ it('inequality and equality on different fields works', () => {
+ return withTestCollection(persistence, {}, async coll => {
+ expect(() =>
+ coll.where('x', '>=', 32).where('y', '==', 'cat')
+ ).not.to.throw();
+ });
+ });
+
+ it('inequality and array-contains on different fields works', () => {
+ return withTestCollection(persistence, {}, async coll => {
+ expect(() =>
+ coll.where('x', '>=', 32).where('y', 'array-contains', 'cat')
+ ).not.to.throw();
+ });
+ });
+
+ it('inequality and IN on different fields works', () => {
+ return withTestCollection(persistence, {}, async coll => {
+ expect(() =>
+ coll.where('x', '>=', 32).where('y', 'in', [1, 2])
+ ).not.to.throw();
+ });
+ });
+
+ it('inequality and array-contains-any on different fields works', () => {
+ return withTestCollection(persistence, {}, async coll => {
+ expect(() =>
+ coll.where('x', '>=', 32).where('y', 'array-contains-any', [1, 2])
+ ).not.to.throw();
+ });
+ });
+
+ it('inequality same as orderBy works.', () => {
+ return withTestCollection(persistence, {}, async coll => {
+ expect(() => coll.where('x', '>', 32).orderBy('x')).not.to.throw();
+ expect(() => coll.orderBy('x').where('x', '>', 32)).not.to.throw();
+ });
+ });
+
+ it('!= same as orderBy works.', () => {
+ return withTestCollection(persistence, {}, async coll => {
+ expect(() => coll.where('x', '!=', 32).orderBy('x')).not.to.throw();
+ expect(() => coll.orderBy('x').where('x', '!=', 32)).not.to.throw();
+ });
+ });
+
+ it('inequality same as first orderBy works.', () => {
+ return withTestCollection(persistence, {}, async coll => {
+ expect(() =>
+ coll.where('x', '>', 32).orderBy('x').orderBy('y')
+ ).not.to.throw();
+ expect(() =>
+ coll.orderBy('x').where('x', '>', 32).orderBy('y')
+ ).not.to.throw();
+ });
+ });
+
+ it('!= same as first orderBy works.', () => {
+ return withTestCollection(persistence, {}, async coll => {
+ expect(() =>
+ coll.where('x', '!=', 32).orderBy('x').orderBy('y')
+ ).not.to.throw();
+ expect(() =>
+ coll.orderBy('x').where('x', '!=', 32).orderBy('y')
+ ).not.to.throw();
+ });
+ });
+
+ it('equality different than orderBy works', () => {
+ return withTestCollection(persistence, {}, async coll => {
+ expect(() => coll.orderBy('x').where('y', '==', 'cat')).not.to.throw();
+ });
+ });
+
+ it('array-contains different than orderBy works', () => {
+ return withTestCollection(persistence, {}, async coll => {
+ expect(() =>
+ coll.orderBy('x').where('y', 'array-contains', 'cat')
+ ).not.to.throw();
+ });
+ });
+
+ it('IN different than orderBy works', () => {
+ return withTestCollection(persistence, {}, async coll => {
+ expect(() => coll.orderBy('x').where('y', 'in', [1, 2])).not.to.throw();
+ });
+ });
+
+ it('array-contains-any different than orderBy works', () => {
+ return withTestCollection(persistence, {}, async coll => {
+ expect(() =>
+ coll.orderBy('x').where('y', 'array-contains-any', [1, 2])
+ ).not.to.throw();
+ });
+ });
+ });
+
+ it('DocumentSnapshot events for non existent document', () => {
+ return withTestCollection(persistence, {}, col => {
+ const doc = col.doc();
+ const storeEvent = new EventsAccumulator();
+ doc.onSnapshot(storeEvent.storeEvent);
+ return storeEvent.awaitEvent().then(snap => {
+ expect(snap.exists).to.be.false;
+ expect(snap.data()).to.equal(undefined);
+ return storeEvent.assertNoAdditionalEvents();
+ });
+ });
+ });
+
+ it('DocumentSnapshot events for add data to document', () => {
+ return withTestCollection(persistence, {}, col => {
+ const doc = col.doc();
+ const storeEvent = new EventsAccumulator();
+ doc.onSnapshot({ includeMetadataChanges: true }, storeEvent.storeEvent);
+ return storeEvent
+ .awaitEvent()
+ .then(snap => {
+ expect(snap.exists).to.be.false;
+ expect(snap.data()).to.equal(undefined);
+ })
+ .then(() => doc.set({ a: 1 }))
+ .then(() => storeEvent.awaitEvent())
+ .then(snap => {
+ expect(snap.exists).to.be.true;
+ expect(snap.data()).to.deep.equal({ a: 1 });
+ expect(snap.metadata.hasPendingWrites).to.be.true;
+ })
+ .then(() => storeEvent.awaitEvent())
+ .then(snap => {
+ expect(snap.exists).to.be.true;
+ expect(snap.data()).to.deep.equal({ a: 1 });
+ expect(snap.metadata.hasPendingWrites).to.be.false;
+ })
+ .then(() => storeEvent.assertNoAdditionalEvents());
+ });
+ });
+
+ it('DocumentSnapshot events for change data in document', () => {
+ const initialData = { a: 1 };
+ const changedData = { b: 2 };
+
+ return withTestCollection(persistence, { key1: initialData }, col => {
+ const doc = col.doc('key1');
+ const storeEvent = new EventsAccumulator();
+ doc.onSnapshot({ includeMetadataChanges: true }, storeEvent.storeEvent);
+ return storeEvent
+ .awaitEvent()
+ .then(snap => {
+ expect(snap.data()).to.deep.equal(initialData);
+ expect(snap.metadata.hasPendingWrites).to.be.false;
+ })
+ .then(() => doc.set(changedData))
+ .then(() => storeEvent.awaitEvent())
+ .then(snap => {
+ expect(snap.data()).to.deep.equal(changedData);
+ expect(snap.metadata.hasPendingWrites).to.be.true;
+ })
+ .then(() => storeEvent.awaitEvent())
+ .then(snap => {
+ expect(snap.data()).to.deep.equal(changedData);
+ expect(snap.metadata.hasPendingWrites).to.be.false;
+ })
+ .then(() => storeEvent.assertNoAdditionalEvents());
+ });
+ });
+
+ it('DocumentSnapshot events for delete data in document', () => {
+ const initialData = { a: 1 };
+
+ return withTestCollection(persistence, { key1: initialData }, col => {
+ const doc = col.doc('key1');
+ const storeEvent = new EventsAccumulator();
+ doc.onSnapshot({ includeMetadataChanges: true }, storeEvent.storeEvent);
+ return storeEvent
+ .awaitEvent()
+ .then(snap => {
+ expect(snap.exists).to.be.true;
+ expect(snap.data()).to.deep.equal(initialData);
+ expect(snap.metadata.hasPendingWrites).to.be.false;
+ })
+ .then(() => doc.delete())
+ .then(() => storeEvent.awaitEvent())
+ .then(snap => {
+ expect(snap.exists).to.be.false;
+ expect(snap.data()).to.equal(undefined);
+ expect(snap.metadata.hasPendingWrites).to.be.false;
+ })
+ .then(() => storeEvent.assertNoAdditionalEvents());
+ });
+ });
+
+ it('Listen can be called multiple times', () => {
+ return withTestCollection(persistence, {}, coll => {
+ const doc = coll.doc();
+ const deferred1 = new Deferred();
+ const deferred2 = new Deferred();
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ doc.set({ foo: 'bar' }).then(() => {
+ doc.onSnapshot(snap => {
+ deferred1.resolve();
+ doc.onSnapshot(snap => {
+ deferred2.resolve();
+ });
+ });
+ });
+ return Promise.all([deferred1.promise, deferred2.promise]).then(() => {});
+ });
+ });
+
+ it('Metadata only changes are not fired when no options provided', () => {
+ return withTestDoc(persistence, docRef => {
+ const secondUpdateFound = new Deferred();
+ let count = 0;
+ const unlisten = docRef.onSnapshot(doc => {
+ if (doc) {
+ count++;
+ if (count === 1) {
+ expect(doc.data()).to.deep.equal({ a: 1 });
+ } else {
+ expect(doc.data()).to.deep.equal({ b: 1 });
+ secondUpdateFound.resolve();
+ }
+ }
+ });
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ docRef.set({ a: 1 }).then(() => {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ docRef.set({ b: 1 });
+ });
+ return secondUpdateFound.promise.then(() => {
+ unlisten();
+ });
+ });
+ });
+
+ // TODO(mikelehen): We need a way to create a query that will pass
+ // client-side validation but fail remotely. May need to wait until we
+ // have security rules support or something?
+ // eslint-disable-next-line no-restricted-properties
+ describe('Listens are rejected remotely:', () => {
+ it('will reject listens', () => {
+ return withTestDb(persistence, async db => {
+ const deferred = new Deferred();
+ const queryForRejection = db.collection('a/__badpath__/b');
+ queryForRejection.onSnapshot(
+ () => {},
+ (err: Error) => {
+ expect(err.name).to.exist;
+ expect(err.message).to.exist;
+ deferred.resolve();
+ }
+ );
+ await deferred.promise;
+ });
+ });
+
+ it('will reject same listens twice in a row', () => {
+ return withTestDb(persistence, async db => {
+ const deferred = new Deferred();
+ const queryForRejection = db.collection('a/__badpath__/b');
+ queryForRejection.onSnapshot(
+ () => {},
+ (err: Error) => {
+ expect(err.name).to.exist;
+ expect(err.message).to.exist;
+ queryForRejection.onSnapshot(
+ () => {},
+ (err2: Error) => {
+ expect(err2.name).to.exist;
+ expect(err2.message).to.exist;
+ deferred.resolve();
+ }
+ );
+ }
+ );
+ await deferred.promise;
+ });
+ });
+
+ it('will reject gets', () => {
+ return withTestDb(persistence, async db => {
+ const queryForRejection = db.collection('a/__badpath__/b');
+ await queryForRejection.get().then(
+ () => {
+ expect.fail('Promise resolved even though error was expected.');
+ },
+ err => {
+ expect(err.name).to.exist;
+ expect(err.message).to.exist;
+ }
+ );
+ });
+ });
+
+ it('will reject gets twice in a row', () => {
+ return withTestDb(persistence, async db => {
+ const queryForRejection = db.collection('a/__badpath__/b');
+ return queryForRejection
+ .get()
+ .then(
+ () => {
+ expect.fail('Promise resolved even though error was expected.');
+ },
+ err => {
+ expect(err.name).to.exist;
+ expect(err.message).to.exist;
+ }
+ )
+ .then(() => queryForRejection.get())
+ .then(
+ () => {
+ expect.fail('Promise resolved even though error was expected.');
+ },
+ err => {
+ expect(err.name).to.exist;
+ expect(err.message).to.exist;
+ }
+ );
+ });
+ });
+ });
+
+ it('exposes "firestore" on document references.', () => {
+ return withTestDb(persistence, async db => {
+ expect(db.doc('foo/bar').firestore).to.equal(db);
+ });
+ });
+
+ it('exposes "firestore" on query references.', () => {
+ return withTestDb(persistence, async db => {
+ expect(db.collection('foo').limit(5).firestore).to.equal(db);
+ });
+ });
+
+ it('can compare DocumentReference instances with isEqual().', () => {
+ return withTestDb(persistence, firestore => {
+ return withTestDb(persistence, async otherFirestore => {
+ const docRef = firestore.doc('foo/bar');
+ expect(docRef.isEqual(firestore.doc('foo/bar'))).to.be.true;
+ expect(docRef.collection('baz').parent!.isEqual(docRef)).to.be.true;
+
+ expect(firestore.doc('foo/BAR').isEqual(docRef)).to.be.false;
+
+ expect(otherFirestore.doc('foo/bar').isEqual(docRef)).to.be.false;
+ });
+ });
+ });
+
+ it('can compare Query instances with isEqual().', () => {
+ return withTestDb(persistence, firestore => {
+ return withTestDb(persistence, async otherFirestore => {
+ const query = firestore
+ .collection('foo')
+ .orderBy('bar')
+ .where('baz', '==', 42);
+ const query2 = firestore
+ .collection('foo')
+ .orderBy('bar')
+ .where('baz', '==', 42);
+ expect(query.isEqual(query2)).to.be.true;
+
+ const query3 = firestore
+ .collection('foo')
+ .orderBy('BAR')
+ .where('baz', '==', 42);
+ expect(query.isEqual(query3)).to.be.false;
+
+ const query4 = otherFirestore
+ .collection('foo')
+ .orderBy('bar')
+ .where('baz', '==', 42);
+ expect(query4.isEqual(query)).to.be.false;
+ });
+ });
+ });
+
+ it('can traverse collections and documents.', () => {
+ return withTestDb(persistence, async db => {
+ const expected = 'a/b/c/d';
+ // doc path from root Firestore.
+ expect(db.doc('a/b/c/d').path).to.deep.equal(expected);
+ // collection path from root Firestore.
+ expect(db.collection('a/b/c').doc('d').path).to.deep.equal(expected);
+ // doc path from CollectionReference.
+ expect(db.collection('a').doc('b/c/d').path).to.deep.equal(expected);
+ // collection path from DocumentReference.
+ expect(db.doc('a/b').collection('c/d/e').path).to.deep.equal(
+ expected + '/e'
+ );
+ });
+ });
+
+ it('can traverse collection and document parents.', () => {
+ return withTestDb(persistence, async db => {
+ let collection = db.collection('a/b/c');
+ expect(collection.path).to.deep.equal('a/b/c');
+
+ const doc = collection.parent!;
+ expect(doc.path).to.deep.equal('a/b');
+
+ collection = doc.parent;
+ expect(collection.path).to.equal('a');
+
+ const nullDoc = collection.parent;
+ expect(nullDoc).to.equal(null);
+ });
+ });
+
+ it('can queue writes while offline', () => {
+ return withTestDoc(persistence, docRef => {
+ const firestore = docRef.firestore;
+
+ return firestore
+ .disableNetwork()
+ .then(() => {
+ return Promise.all([
+ docRef.set({ foo: 'bar' }),
+ firestore.enableNetwork()
+ ]);
+ })
+ .then(() => docRef.get())
+ .then(doc => {
+ expect(doc.data()).to.deep.equal({ foo: 'bar' });
+ });
+ });
+ });
+
+ // eslint-disable-next-line no-restricted-properties
+ (persistence ? it : it.skip)('offline writes are sent after restart', () => {
+ return withTestDoc(persistence, async docRef => {
+ const firestore = docRef.firestore;
+
+ const app = firestore.app;
+ const name = app.name;
+ const options = app.options;
+
+ await firestore.disableNetwork();
+
+ // We are merely adding to the cache.
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ docRef.set({ foo: 'bar' });
+
+ await app.delete();
+
+ const firestore2 = newTestFirestore(
+ options.projectId,
+ name,
+ DEFAULT_SETTINGS
+ );
+ await firestore2.enablePersistence();
+ await firestore2.waitForPendingWrites();
+ const doc = await firestore2.doc(docRef.path).get();
+
+ expect(doc.exists).to.be.true;
+ expect(doc.metadata.hasPendingWrites).to.be.false;
+ });
+ });
+
+ it('rejects subsequent method calls after terminate() is called', async () => {
+ return withTestDb(persistence, db => {
+ return db.INTERNAL.delete().then(() => {
+ expect(() => {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ db.disableNetwork();
+ }).to.throw('The client has already been terminated.');
+ });
+ });
+ });
+
+ it('can call terminate() multiple times', async () => {
+ return withTestDb(persistence, async db => {
+ await db.terminate();
+ await db.terminate();
+ });
+ });
+
+ // eslint-disable-next-line no-restricted-properties
+ (persistence ? it : it.skip)(
+ 'maintains persistence after restarting app',
+ async () => {
+ await withTestDoc(persistence, async docRef => {
+ await docRef.set({ foo: 'bar' });
+ const app = docRef.firestore.app;
+ const name = app.name;
+ const options = app.options;
+
+ await app.delete();
+
+ const firestore2 = newTestFirestore(options.projectId, name);
+ await firestore2.enablePersistence();
+ const docRef2 = firestore2.doc(docRef.path);
+ const docSnap2 = await docRef2.get({ source: 'cache' });
+ expect(docSnap2.exists).to.be.true;
+ });
+ }
+ );
+
+ // eslint-disable-next-line no-restricted-properties
+ (persistence ? it : it.skip)(
+ 'can clear persistence if the client has been terminated',
+ async () => {
+ await withTestDoc(persistence, async docRef => {
+ const firestore = docRef.firestore;
+ await docRef.set({ foo: 'bar' });
+ const app = docRef.firestore.app;
+ const name = app.name;
+ const options = app.options;
+
+ await app.delete();
+ await firestore.clearPersistence();
+ const firestore2 = newTestFirestore(options.projectId, name);
+ await firestore2.enablePersistence();
+ const docRef2 = firestore2.doc(docRef.path);
+ await expect(
+ docRef2.get({ source: 'cache' })
+ ).to.eventually.be.rejectedWith('Failed to get document from cache.');
+ });
+ }
+ );
+
+ // eslint-disable-next-line no-restricted-properties
+ (persistence ? it : it.skip)(
+ 'can clear persistence if the client has not been initialized',
+ async () => {
+ await withTestDoc(persistence, async docRef => {
+ await docRef.set({ foo: 'bar' });
+ const app = docRef.firestore.app;
+ const name = app.name;
+ const options = app.options;
+
+ await app.delete();
+ const firestore2 = newTestFirestore(options.projectId, name);
+ await firestore2.clearPersistence();
+ await firestore2.enablePersistence();
+ const docRef2 = firestore2.doc(docRef.path);
+ await expect(
+ docRef2.get({ source: 'cache' })
+ ).to.eventually.be.rejectedWith('Failed to get document from cache.');
+ });
+ }
+ );
+
+ // eslint-disable-next-line no-restricted-properties
+ (persistence ? it : it.skip)(
+ 'cannot clear persistence if the client has been initialized',
+ async () => {
+ await withTestDoc(persistence, async docRef => {
+ const firestore = docRef.firestore;
+ const expectedError =
+ 'Persistence can only be cleared before a Firestore instance is ' +
+ 'initialized or after it is terminated.';
+ expect(() => firestore.clearPersistence()).to.throw(expectedError);
+ });
+ }
+ );
+
+ it('can get documents while offline', async () => {
+ await withTestDoc(persistence, async docRef => {
+ const firestore = docRef.firestore;
+
+ await firestore.disableNetwork();
+ await expect(docRef.get()).to.eventually.be.rejectedWith(
+ 'Failed to get document because the client is offline.'
+ );
+
+ const writePromise = docRef.set({ foo: 'bar' });
+ const doc = await docRef.get();
+ expect(doc.metadata.fromCache).to.be.true;
+
+ await firestore.enableNetwork();
+ await writePromise;
+
+ const doc2 = await docRef.get();
+ expect(doc2.metadata.fromCache).to.be.false;
+ expect(doc2.data()).to.deep.equal({ foo: 'bar' });
+ });
+ });
+
+ it('can enable and disable networking', () => {
+ return withTestDb(persistence, async db => {
+ // There's not currently a way to check if networking is in fact disabled,
+ // so for now just test that the method is well-behaved and doesn't throw.
+ await db.enableNetwork();
+ await db.enableNetwork();
+ await db.disableNetwork();
+ await db.disableNetwork();
+ await db.enableNetwork();
+ });
+ });
+
+ it('can start a new instance after shut down', async () => {
+ return withTestDoc(persistence, async docRef => {
+ const firestore = docRef.firestore;
+ await firestore.terminate();
+
+ const newFirestore = newTestFirestore(
+ firestore.app.options.projectId,
+ firestore.app
+ );
+ expect(newFirestore).to.not.equal(firestore);
+
+ // New instance functions.
+ newFirestore.settings(DEFAULT_SETTINGS);
+ await newFirestore.doc(docRef.path).set({ foo: 'bar' });
+ const doc = await newFirestore.doc(docRef.path).get();
+ expect(doc.data()).to.deep.equal({ foo: 'bar' });
+ });
+ });
+
+ it('new operation after termination should throw', async () => {
+ await withTestDoc(persistence, async docRef => {
+ const firestore = docRef.firestore;
+ await firestore.terminate();
+
+ expect(() => {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ firestore.doc(docRef.path).set({ foo: 'bar' });
+ }).to.throw('The client has already been terminated.');
+ });
+ });
+
+ it('calling terminate multiple times should proceed', async () => {
+ await withTestDoc(persistence, async docRef => {
+ const firestore = docRef.firestore;
+ await firestore.terminate();
+ await firestore.terminate();
+
+ expect(() => {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ firestore.doc(docRef.path).set({ foo: 'bar' });
+ }).to.throw();
+ });
+ });
+
+ it('can unlisten queries after termination', async () => {
+ return withTestDoc(persistence, async docRef => {
+ const firestore = docRef.firestore;
+ const accumulator = new EventsAccumulator();
+ const unsubscribe = docRef.onSnapshot(accumulator.storeEvent);
+ await accumulator.awaitEvent();
+ await firestore.terminate();
+
+ // This should proceed without error.
+ unsubscribe();
+ // Multiple calls should proceed as well.
+ unsubscribe();
+ });
+ });
+
+ it('can wait for pending writes', async () => {
+ await withTestDoc(persistence, async docRef => {
+ const firestore = docRef.firestore;
+ // Prevent pending writes receiving acknowledgement.
+ await firestore.disableNetwork();
+
+ const pendingWrites = docRef.set({ foo: 'bar' });
+ const awaitPendingWrites = firestore.waitForPendingWrites();
+
+ // pending writes can receive acknowledgements now.
+ await firestore.enableNetwork();
+ await pendingWrites;
+ await awaitPendingWrites;
+ });
+ });
+
+ it('waiting for pending writes resolves immediately when offline and no pending writes', async () => {
+ await withTestDoc(persistence, async docRef => {
+ const firestore = docRef.firestore;
+ // Prevent pending writes receiving acknowledgement.
+ await firestore.disableNetwork();
+
+ // `awaitsPendingWrites` is created when there is no pending writes, it will resolve
+ // immediately even if we are offline.
+ await firestore.waitForPendingWrites();
+ });
+ });
+
+ // PORTING NOTE: These tests are for FirestoreDataConverter support and apply
+ // only to web.
+ apiDescribe('withConverter() support', (persistence: boolean) => {
+ class Post {
+ constructor(
+ readonly title: string,
+ readonly author: string,
+ readonly ref: firestore.DocumentReference | null = null
+ ) {}
+ byline(): string {
+ return this.title + ', by ' + this.author;
+ }
+ }
+
+ const postConverter = {
+ toFirestore(post: Post): firestore.DocumentData {
+ return { title: post.title, author: post.author };
+ },
+ fromFirestore(
+ snapshot: firestore.QueryDocumentSnapshot,
+ options: firestore.SnapshotOptions
+ ): Post {
+ expect(snapshot).to.be.an.instanceof(QueryDocumentSnapshot);
+ const data = snapshot.data(options);
+ return new Post(data.title, data.author, snapshot.ref);
+ }
+ };
+
+ const postConverterMerge = {
+ toFirestore(
+ post: Partial,
+ options?: firestore.SetOptions
+ ): firestore.DocumentData {
+ if (options && (options.merge || options.mergeFields)) {
+ expect(post).to.not.be.an.instanceof(Post);
+ } else {
+ expect(post).to.be.an.instanceof(Post);
+ }
+ const result: firestore.DocumentData = {};
+ if (post.title) {
+ result.title = post.title;
+ }
+ if (post.author) {
+ result.author = post.author;
+ }
+ return result;
+ },
+ fromFirestore(
+ snapshot: firestore.QueryDocumentSnapshot,
+ options: firestore.SnapshotOptions
+ ): Post {
+ const data = snapshot.data();
+ return new Post(data.title, data.author, snapshot.ref);
+ }
+ };
+
+ it('for DocumentReference.withConverter()', () => {
+ return withTestDb(persistence, async db => {
+ const docRef = db
+ .collection('posts')
+ .doc()
+ .withConverter(postConverter);
+
+ await docRef.set(new Post('post', 'author'));
+ const postData = await docRef.get();
+ const post = postData.data();
+ expect(post).to.not.equal(undefined);
+ expect(post!.byline()).to.equal('post, by author');
+ });
+ });
+
+ it('for DocumentReference.withConverter(null) ', () => {
+ return withTestDb(persistence, async db => {
+ const docRef = db
+ .collection('posts')
+ .doc()
+ .withConverter(postConverter)
+ .withConverter(null);
+
+ expect(() => docRef.set(new Post('post', 'author'))).to.throw();
+ });
+ });
+
+ it('for CollectionReference.withConverter()', () => {
+ return withTestDb(persistence, async db => {
+ const coll = db.collection('posts').withConverter(postConverter);
+
+ const docRef = await coll.add(new Post('post', 'author'));
+ const postData = await docRef.get();
+ const post = postData.data();
+ expect(post).to.not.equal(undefined);
+ expect(post!.byline()).to.equal('post, by author');
+ });
+ });
+
+ it('for CollectionReference.withConverter(null)', () => {
+ return withTestDb(persistence, async db => {
+ const coll = db
+ .collection('posts')
+ .withConverter(postConverter)
+ .withConverter(null);
+
+ expect(() => coll.add(new Post('post', 'author'))).to.throw();
+ });
+ });
+
+ it('for Query.withConverter()', () => {
+ return withTestDb(persistence, async db => {
+ await db
+ .doc('postings/post1')
+ .set({ title: 'post1', author: 'author1' });
+ await db
+ .doc('postings/post2')
+ .set({ title: 'post2', author: 'author2' });
+ const posts = await db
+ .collectionGroup('postings')
+ .withConverter(postConverter)
+ .get();
+ expect(posts.size).to.equal(2);
+ expect(posts.docs[0].data()!.byline()).to.equal('post1, by author1');
+ });
+ });
+
+ it('for Query.withConverter(null)', () => {
+ return withTestDb(persistence, async db => {
+ await db
+ .doc('postings/post1')
+ .set({ title: 'post1', author: 'author1' });
+ const posts = await db
+ .collectionGroup('postings')
+ .withConverter(postConverter)
+ .withConverter(null)
+ .get();
+ expect(posts.docs[0].data()).to.not.be.an.instanceof(Post);
+ });
+ });
+
+ it('requires the correct converter for Partial usage', async () => {
+ return withTestDb(persistence, async db => {
+ const ref = db
+ .collection('posts')
+ .doc('some-post')
+ .withConverter(postConverter);
+ await ref.set(new Post('walnut', 'author'));
+ const batch = db.batch();
+ expect(() =>
+ batch.set(ref, { title: 'olive' }, { merge: true })
+ ).to.throw(
+ 'Function WriteBatch.set() called with invalid ' +
+ 'data (via `toFirestore()`). Unsupported field value: undefined ' +
+ '(found in field author in document posts/some-post)'
+ );
+ });
+ });
+
+ it('WriteBatch.set() supports partials with merge', async () => {
+ return withTestDb(persistence, async db => {
+ const ref = db
+ .collection('posts')
+ .doc()
+ .withConverter(postConverterMerge);
+ await ref.set(new Post('walnut', 'author'));
+ const batch = db.batch();
+ batch.set(ref, { title: 'olive' }, { merge: true });
+ await batch.commit();
+ const doc = await ref.get();
+ expect(doc.get('title')).to.equal('olive');
+ expect(doc.get('author')).to.equal('author');
+ });
+ });
+
+ it('WriteBatch.set() supports partials with mergeFields', async () => {
+ return withTestDb(persistence, async db => {
+ const ref = db
+ .collection('posts')
+ .doc()
+ .withConverter(postConverterMerge);
+ await ref.set(new Post('walnut', 'author'));
+ const batch = db.batch();
+ batch.set(
+ ref,
+ { title: 'olive', author: 'writer' },
+ { mergeFields: ['title'] }
+ );
+ await batch.commit();
+ const doc = await ref.get();
+ expect(doc.get('title')).to.equal('olive');
+ expect(doc.get('author')).to.equal('author');
+ });
+ });
+
+ it('Transaction.set() supports partials with merge', async () => {
+ return withTestDb(persistence, async db => {
+ const ref = db
+ .collection('posts')
+ .doc()
+ .withConverter(postConverterMerge);
+ await ref.set(new Post('walnut', 'author'));
+ await db.runTransaction(async tx => {
+ tx.set(ref, { title: 'olive' }, { merge: true });
+ });
+ const doc = await ref.get();
+ expect(doc.get('title')).to.equal('olive');
+ expect(doc.get('author')).to.equal('author');
+ });
+ });
+
+ it('Transaction.set() supports partials with mergeFields', async () => {
+ return withTestDb(persistence, async db => {
+ const ref = db
+ .collection('posts')
+ .doc()
+ .withConverter(postConverterMerge);
+ await ref.set(new Post('walnut', 'author'));
+ await db.runTransaction(async tx => {
+ tx.set(
+ ref,
+ { title: 'olive', author: 'person' },
+ { mergeFields: ['title'] }
+ );
+ });
+ const doc = await ref.get();
+ expect(doc.get('title')).to.equal('olive');
+ expect(doc.get('author')).to.equal('author');
+ });
+ });
+
+ it('DocumentReference.set() supports partials with merge', async () => {
+ return withTestDb(persistence, async db => {
+ const ref = db
+ .collection('posts')
+ .doc()
+ .withConverter(postConverterMerge);
+ await ref.set(new Post('walnut', 'author'));
+ await ref.set({ title: 'olive' }, { merge: true });
+ const doc = await ref.get();
+ expect(doc.get('title')).to.equal('olive');
+ expect(doc.get('author')).to.equal('author');
+ });
+ });
+
+ it('DocumentReference.set() supports partials with mergeFields', async () => {
+ return withTestDb(persistence, async db => {
+ const ref = db
+ .collection('posts')
+ .doc()
+ .withConverter(postConverterMerge);
+ await ref.set(new Post('walnut', 'author'));
+ await ref.set(
+ { title: 'olive', author: 'writer' },
+ { mergeFields: ['title'] }
+ );
+ const doc = await ref.get();
+ expect(doc.get('title')).to.equal('olive');
+ expect(doc.get('author')).to.equal('author');
+ });
+ });
+
+ it('calls DocumentSnapshot.data() with specified SnapshotOptions', () => {
+ return withTestDb(persistence, async db => {
+ const docRef = db.doc('some/doc').withConverter({
+ toFirestore(post: Post): firestore.DocumentData {
+ return { title: post.title, author: post.author };
+ },
+ fromFirestore(
+ snapshot: firestore.QueryDocumentSnapshot,
+ options: firestore.SnapshotOptions
+ ): Post {
+ // Check that options were passed in properly.
+ expect(options).to.deep.equal({ serverTimestamps: 'estimate' });
+
+ const data = snapshot.data(options);
+ return new Post(data.title, data.author, snapshot.ref);
+ }
+ });
+
+ await docRef.set(new Post('post', 'author'));
+ const postData = await docRef.get();
+ postData.data({ serverTimestamps: 'estimate' });
+ });
+ });
+
+ it('drops the converter when calling CollectionReference.parent()', () => {
+ return withTestDb(persistence, async db => {
+ const postsCollection = db
+ .collection('users/user1/posts')
+ .withConverter(postConverter);
+
+ const usersCollection = postsCollection.parent;
+ expect(usersCollection!.isEqual(db.doc('users/user1'))).to.be.true;
+ });
+ });
+
+ it('checks converter when comparing with isEqual()', () => {
+ return withTestDb(persistence, async db => {
+ const postConverter2 = { ...postConverter };
+
+ const postsCollection = db
+ .collection('users/user1/posts')
+ .withConverter(postConverter);
+ const postsCollection2 = db
+ .collection('users/user1/posts')
+ .withConverter(postConverter2);
+ expect(postsCollection.isEqual(postsCollection2)).to.be.false;
+
+ const docRef = db.doc('some/doc').withConverter(postConverter);
+ const docRef2 = db.doc('some/doc').withConverter(postConverter2);
+ expect(docRef.isEqual(docRef2)).to.be.false;
+ });
+ });
+
+ it('Correct snapshot specified to fromFirestore() when registered with DocumentReference', () => {
+ return withTestDb(persistence, async db => {
+ const untypedDocRef = db.collection('/models').doc();
+ const docRef = untypedDocRef.withConverter(postConverter);
+ await docRef.set(new Post('post', 'author'));
+ const docSnapshot = await docRef.get();
+ const ref = docSnapshot.data()!.ref!;
+ expect(ref).to.be.an.instanceof(DocumentReference);
+ expect(untypedDocRef.isEqual(ref)).to.be.true;
+ });
+ });
+
+ it('Correct snapshot specified to fromFirestore() when registered with CollectionReference', () => {
+ return withTestDb(persistence, async db => {
+ const untypedCollection = db
+ .collection('/models')
+ .doc()
+ .collection('sub');
+ const collection = untypedCollection.withConverter(postConverter);
+ const docRef = collection.doc();
+ await docRef.set(new Post('post', 'author', docRef));
+ const querySnapshot = await collection.get();
+ expect(querySnapshot.size).to.equal(1);
+ const ref = querySnapshot.docs[0].data().ref!;
+ expect(ref).to.be.an.instanceof(DocumentReference);
+ const untypedDocRef = untypedCollection.doc(docRef.id);
+ expect(untypedDocRef.isEqual(ref)).to.be.true;
+ });
+ });
+
+ it('Correct snapshot specified to fromFirestore() when registered with Query', () => {
+ return withTestDb(persistence, async db => {
+ const untypedCollection = db.collection('/models');
+ const untypedDocRef = untypedCollection.doc();
+ const docRef = untypedDocRef.withConverter(postConverter);
+ await docRef.set(new Post('post', 'author', docRef));
+ const query = untypedCollection
+ .where(FieldPath.documentId(), '==', docRef.id)
+ .withConverter(postConverter);
+ const querySnapshot = await query.get();
+ expect(querySnapshot.size).to.equal(1);
+ const ref = querySnapshot.docs[0].data().ref!;
+ expect(ref).to.be.an.instanceof(DocumentReference);
+ expect(untypedDocRef.isEqual(ref)).to.be.true;
+ });
+ });
+ });
+
+ // TODO(b/196858864): This test regularly times out on CI.
+ // eslint-disable-next-line no-restricted-properties
+ it.skip('can set and get data with auto detect long polling enabled', () => {
+ const settings = {
+ ...DEFAULT_SETTINGS,
+ experimentalAutoDetectLongPolling: true
+ };
+
+ return withTestDbsSettings(
+ persistence,
+ DEFAULT_PROJECT_ID,
+ settings,
+ 1,
+ async ([db]) => {
+ const data = { name: 'Rafi', email: 'abc@xyz.com' };
+ const doc = await db.collection('users').doc();
+
+ return doc
+ .set(data)
+ .then(() => doc.get())
+ .then(snapshot => {
+ expect(snapshot.exists).to.be.ok;
+ expect(snapshot.data()).to.deep.equal(data);
+ });
+ }
+ );
+ });
+
+ it('app delete leads to instance termination', async () => {
+ await withTestDoc(persistence, async docRef => {
+ await docRef.set({ foo: 'bar' });
+ const app = docRef.firestore.app;
+ await app.delete();
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ expect((docRef.firestore as any)._delegate._terminated).to.be.true;
+ });
+ });
+});
diff --git a/packages/firestore-compat/test/fields.test.ts b/packages/firestore-compat/test/fields.test.ts
new file mode 100644
index 00000000000..6c72572d82d
--- /dev/null
+++ b/packages/firestore-compat/test/fields.test.ts
@@ -0,0 +1,453 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { expect } from 'chai';
+
+import * as firebaseExport from './util/firebase_export';
+import {
+ apiDescribe,
+ toDataArray,
+ withTestCollection,
+ withTestCollectionSettings,
+ withTestDoc,
+ withTestDocAndSettings
+} from './util/helpers';
+import { DEFAULT_SETTINGS } from './util/settings';
+
+const FieldPath = firebaseExport.FieldPath;
+const Timestamp = firebaseExport.Timestamp;
+
+// Allow custom types for testing.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type AnyTestData = any;
+
+apiDescribe('Nested Fields', (persistence: boolean) => {
+ const testData = (n?: number): AnyTestData => {
+ n = n || 1;
+ return {
+ name: 'room ' + n,
+ metadata: {
+ createdAt: n,
+ deep: {
+ field: 'deep-field-' + n
+ }
+ }
+ };
+ };
+
+ it('can be written with set()', () => {
+ return withTestDoc(persistence, doc => {
+ return doc
+ .set(testData())
+ .then(() => doc.get())
+ .then(docSnap => {
+ expect(docSnap.data()).to.deep.equal(testData());
+ });
+ });
+ });
+
+ it('can be read directly with .get()', () => {
+ return withTestDoc(persistence, doc => {
+ const obj = testData();
+ return doc
+ .set(obj)
+ .then(() => doc.get())
+ .then(docSnap => {
+ expect(docSnap.data()).to.deep.equal(obj);
+ expect(docSnap.get('name')).to.deep.equal(obj.name);
+ expect(docSnap.get('metadata')).to.deep.equal(obj.metadata);
+ expect(docSnap.get('metadata.deep.field')).to.deep.equal(
+ obj.metadata.deep.field
+ );
+ expect(docSnap.get('metadata.nofield')).to.be.undefined;
+ expect(docSnap.get('nometadata.nofield')).to.be.undefined;
+ });
+ });
+ });
+
+ it('can be read directly with .get()', () => {
+ return withTestDoc(persistence, doc => {
+ const obj = testData();
+ return doc
+ .set(obj)
+ .then(() => doc.get())
+ .then(docSnap => {
+ expect(docSnap.data()).to.deep.equal(obj);
+ expect(docSnap.get(new FieldPath('name'))).to.deep.equal(obj.name);
+ expect(docSnap.get(new FieldPath('metadata'))).to.deep.equal(
+ obj.metadata
+ );
+ expect(
+ docSnap.get(new FieldPath('metadata', 'deep', 'field'))
+ ).to.deep.equal(obj.metadata.deep.field);
+ expect(docSnap.get(new FieldPath('metadata', 'nofield'))).to.be
+ .undefined;
+ expect(docSnap.get(new FieldPath('nometadata', 'nofield'))).to.be
+ .undefined;
+ });
+ });
+ });
+
+ it('can be updated with update()', () => {
+ return withTestDoc(persistence, doc => {
+ return doc
+ .set(testData())
+ .then(() => {
+ return doc.update({
+ 'metadata.deep.field': 100,
+ 'metadata.added': 200
+ });
+ })
+ .then(() => doc.get())
+ .then(docSnap => {
+ expect(docSnap.data()).to.deep.equal({
+ name: 'room 1',
+ metadata: {
+ createdAt: 1,
+ deep: {
+ field: 100
+ },
+ added: 200
+ }
+ });
+ });
+ });
+ });
+
+ it('can be updated with update()', () => {
+ return withTestDoc(persistence, doc => {
+ return doc
+ .set(testData())
+ .then(() => {
+ return doc.update(
+ new FieldPath('metadata', 'deep', 'field'),
+ 100,
+ new FieldPath('metadata', 'added'),
+ 200
+ );
+ })
+ .then(() => doc.get())
+ .then(docSnap => {
+ expect(docSnap.data()).to.deep.equal({
+ name: 'room 1',
+ metadata: {
+ createdAt: 1,
+ deep: {
+ field: 100
+ },
+ added: 200
+ }
+ });
+ });
+ });
+ });
+
+ it('can be used with query.where().', () => {
+ const testDocs = {
+ '1': testData(300),
+ '2': testData(100),
+ '3': testData(200)
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ return coll
+ .where('metadata.createdAt', '>=', 200)
+ .get()
+ .then(results => {
+ // inequality adds implicit sort on field
+ expect(toDataArray(results)).to.deep.equal([
+ testData(200),
+ testData(300)
+ ]);
+ });
+ });
+ });
+
+ it('can be used with query.where().', () => {
+ const testDocs = {
+ '1': testData(300),
+ '2': testData(100),
+ '3': testData(200)
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ return coll
+ .where(new FieldPath('metadata', 'createdAt'), '>=', 200)
+ .get()
+ .then(results => {
+ // inequality adds implicit sort on field
+ expect(toDataArray(results)).to.deep.equal([
+ testData(200),
+ testData(300)
+ ]);
+ });
+ });
+ });
+
+ it('can be used with query.orderBy().', () => {
+ const testDocs = {
+ '1': testData(300),
+ '2': testData(100),
+ '3': testData(200)
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ return coll
+ .orderBy('metadata.createdAt')
+ .get()
+ .then(results => {
+ expect(toDataArray(results)).to.deep.equal([
+ testData(100),
+ testData(200),
+ testData(300)
+ ]);
+ });
+ });
+ });
+
+ it('can be used with query.orderBy().', () => {
+ const testDocs = {
+ '1': testData(300),
+ '2': testData(100),
+ '3': testData(200)
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ return coll
+ .orderBy(new FieldPath('metadata', 'createdAt'))
+ .get()
+ .then(results => {
+ expect(toDataArray(results)).to.deep.equal([
+ testData(100),
+ testData(200),
+ testData(300)
+ ]);
+ });
+ });
+ });
+});
+
+// NOTE(mikelehen): I originally combined these tests with the above ones, but
+// Datastore currently prohibits having nested fields and fields with dots in
+// the same entity, so I'm separating them.
+apiDescribe('Fields with special characters', (persistence: boolean) => {
+ const testData = (n?: number): AnyTestData => {
+ n = n || 1;
+ return {
+ field: 'field ' + n,
+ 'field.dot': n,
+ 'field\\slash': n
+ };
+ };
+
+ it('can be written with set()', () => {
+ return withTestDoc(persistence, doc => {
+ return doc
+ .set(testData())
+ .then(() => doc.get())
+ .then(docSnap => {
+ expect(docSnap.data()).to.deep.equal(testData());
+ });
+ });
+ });
+
+ it('can be read directly with .data()', () => {
+ return withTestDoc(persistence, doc => {
+ const obj = testData();
+ return doc
+ .set(obj)
+ .then(() => doc.get())
+ .then(docSnap => {
+ expect(docSnap.data()).to.deep.equal(obj);
+ expect(docSnap.get(new FieldPath('field.dot'))).to.deep.equal(
+ obj['field.dot']
+ );
+ expect(docSnap.get('field\\slash')).to.deep.equal(
+ obj['field\\slash']
+ );
+ });
+ });
+ });
+
+ it('can be updated with update()', () => {
+ return withTestDoc(persistence, doc => {
+ return doc
+ .set(testData())
+ .then(() => {
+ return doc.update(
+ new FieldPath('field.dot'),
+ 100,
+ 'field\\slash',
+ 200
+ );
+ })
+ .then(() => doc.get())
+ .then(docSnap => {
+ expect(docSnap.data()).to.deep.equal({
+ field: 'field 1',
+ 'field.dot': 100,
+ 'field\\slash': 200
+ });
+ });
+ });
+ });
+
+ it('can be used in query filters.', () => {
+ const testDocs = {
+ '1': testData(300),
+ '2': testData(100),
+ '3': testData(200)
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ // inequality adds implicit sort on field
+ const expected = [testData(200), testData(300)];
+ return coll
+ .where(new FieldPath('field.dot'), '>=', 200)
+ .get()
+ .then(results => {
+ expect(toDataArray(results)).to.deep.equal(expected);
+ })
+ .then(() => coll.where('field\\slash', '>=', 200).get())
+ .then(results => {
+ expect(toDataArray(results)).to.deep.equal(expected);
+ });
+ });
+ });
+
+ it('can be used in a query orderBy.', () => {
+ const testDocs = {
+ '1': testData(300),
+ '2': testData(100),
+ '3': testData(200)
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ const expected = [testData(100), testData(200), testData(300)];
+ return coll
+ .orderBy(new FieldPath('field.dot'))
+ .get()
+ .then(results => {
+ expect(toDataArray(results)).to.deep.equal(expected);
+ })
+ .then(() => coll.orderBy('field\\slash').get())
+ .then(results => {
+ expect(toDataArray(results)).to.deep.equal(expected);
+ });
+ });
+ });
+});
+
+apiDescribe('Timestamp Fields in snapshots', (persistence: boolean) => {
+ // Figure out how to pass in the Timestamp type
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const testDataWithTimestamps = (ts: any): AnyTestData => {
+ return { timestamp: ts, nested: { timestamp2: ts } };
+ };
+
+ it('are returned as Timestamps', () => {
+ const timestamp = new Timestamp(100, 123456000);
+ // Timestamps are currently truncated to microseconds after being written to
+ // the database, so a truncated version of the timestamp is needed for
+ // comparisons.
+ const truncatedTimestamp = new Timestamp(
+ timestamp.seconds,
+ Math.floor(timestamp.nanoseconds / 1000) * 1000
+ );
+
+ return withTestDoc(persistence, doc => {
+ return doc
+ .set(testDataWithTimestamps(timestamp))
+ .then(() => doc.get())
+ .then(docSnap => {
+ expect(docSnap.get('timestamp'))
+ .to.be.an.instanceof(Timestamp)
+ .that.deep.equals(truncatedTimestamp);
+ expect(docSnap.data()!['timestamp'])
+ .to.be.an.instanceof(Timestamp)
+ .that.deep.equals(truncatedTimestamp);
+
+ expect(docSnap.get('nested.timestamp2'))
+ .to.be.an.instanceof(Timestamp)
+ .that.deep.equals(truncatedTimestamp);
+ expect(docSnap.data()!['nested']['timestamp2'])
+ .to.be.an.instanceof(Timestamp)
+ .that.deep.equals(truncatedTimestamp);
+ });
+ });
+ });
+});
+
+apiDescribe('`undefined` properties', (persistence: boolean) => {
+ const settings = { ...DEFAULT_SETTINGS };
+ settings.ignoreUndefinedProperties = true;
+
+ it('are ignored in set()', () => {
+ return withTestDocAndSettings(persistence, settings, async doc => {
+ await doc.set({ foo: 'foo', 'bar': undefined });
+ const docSnap = await doc.get();
+ expect(docSnap.data()).to.deep.equal({ foo: 'foo' });
+ });
+ });
+
+ it('are ignored in set({ merge: true })', () => {
+ return withTestDocAndSettings(persistence, settings, async doc => {
+ await doc.set({ foo: 'foo', bar: 'unchanged' });
+ await doc.set({ foo: 'foo', bar: undefined }, { merge: true });
+ const docSnap = await doc.get();
+ expect(docSnap.data()).to.deep.equal({ foo: 'foo', bar: 'unchanged' });
+ });
+ });
+
+ it('are ignored in update()', () => {
+ return withTestDocAndSettings(persistence, settings, async doc => {
+ await doc.set({});
+ await doc.update({ a: { foo: 'foo', 'bar': undefined } });
+ await doc.update('b', { foo: 'foo', 'bar': undefined });
+ const docSnap = await doc.get();
+ expect(docSnap.data()).to.deep.equal({
+ a: { foo: 'foo' },
+ b: { foo: 'foo' }
+ });
+ });
+ });
+
+ it('are ignored in Query.where()', () => {
+ return withTestCollectionSettings(
+ persistence,
+ settings,
+ { 'doc1': { nested: { foo: 'foo' } } },
+ async coll => {
+ const query = coll.where('nested', '==', {
+ foo: 'foo',
+ 'bar': undefined
+ });
+ const querySnap = await query.get();
+ expect(querySnap.size).to.equal(1);
+ }
+ );
+ });
+
+ it('are ignored in Query.startAt()', () => {
+ return withTestCollectionSettings(
+ persistence,
+ settings,
+ { 'doc1': { nested: { foo: 'foo' } } },
+ async coll => {
+ const query = coll
+ .orderBy('nested')
+ .startAt({ foo: 'foo', 'bar': undefined });
+ const querySnap = await query.get();
+ expect(querySnap.size).to.equal(1);
+ }
+ );
+ });
+});
diff --git a/packages/firestore-compat/test/get_options.test.ts b/packages/firestore-compat/test/get_options.test.ts
new file mode 100644
index 00000000000..b5b1bb3afd7
--- /dev/null
+++ b/packages/firestore-compat/test/get_options.test.ts
@@ -0,0 +1,606 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { expect } from 'chai';
+
+import {
+ apiDescribe,
+ toDataMap,
+ withTestCollection,
+ withTestDocAndInitialData
+} from './util/helpers';
+
+apiDescribe('GetOptions', (persistence: boolean) => {
+ it('get document while online with default get options', () => {
+ const initialData = { key: 'value' };
+ return withTestDocAndInitialData(persistence, initialData, docRef => {
+ return docRef.get().then(doc => {
+ expect(doc.exists).to.be.true;
+ expect(doc.metadata.fromCache).to.be.false;
+ expect(doc.metadata.hasPendingWrites).to.be.false;
+ expect(doc.data()).to.deep.equal(initialData);
+ });
+ });
+ });
+
+ it('get collection while online with default get options', () => {
+ const initialDocs = {
+ doc1: { key1: 'value1' },
+ doc2: { key2: 'value2' },
+ doc3: { key3: 'value3' }
+ };
+ return withTestCollection(persistence, initialDocs, colRef => {
+ return colRef.get().then(qrySnap => {
+ expect(qrySnap.metadata.fromCache).to.be.false;
+ expect(qrySnap.metadata.hasPendingWrites).to.be.false;
+ expect(qrySnap.docChanges().length).to.equal(3);
+ expect(toDataMap(qrySnap)).to.deep.equal(initialDocs);
+ });
+ });
+ });
+
+ it('get document while offline with default get options', () => {
+ const initialData = { key: 'value' };
+ return withTestDocAndInitialData(persistence, initialData, docRef => {
+ // Register a snapshot to force the data to stay in the cache and not be
+ // garbage collected.
+ docRef.onSnapshot(() => {});
+ return docRef
+ .get()
+ .then(ignored => docRef.firestore.disableNetwork())
+ .then(() => docRef.get())
+ .then(doc => {
+ expect(doc.exists).to.be.true;
+ expect(doc.metadata.fromCache).to.be.true;
+ expect(doc.metadata.hasPendingWrites).to.be.false;
+ expect(doc.data()).to.deep.equal(initialData);
+ });
+ });
+ });
+
+ it('get collection while offline with default get options', () => {
+ const initialDocs = {
+ doc1: { key1: 'value1' },
+ doc2: { key2: 'value2' },
+ doc3: { key3: 'value3' }
+ };
+ return withTestCollection(persistence, initialDocs, colRef => {
+ // Register a snapshot to force the data to stay in the cache and not be
+ // garbage collected.
+ colRef.onSnapshot(() => {});
+ return colRef
+ .get()
+ .then(ignored => colRef.firestore.disableNetwork())
+ .then(() => {
+ // NB: since we're offline, the returned promises won't complete
+ /* eslint-disable @typescript-eslint/no-floating-promises */
+ colRef.doc('doc2').set({ key2b: 'value2b' }, { merge: true });
+ colRef.doc('doc3').set({ key3b: 'value3b' });
+ colRef.doc('doc4').set({ key4: 'value4' });
+ /* eslint-enable @typescript-eslint/no-floating-promises */
+ return colRef.get();
+ })
+ .then(qrySnap => {
+ expect(qrySnap.metadata.fromCache).to.be.true;
+ expect(qrySnap.metadata.hasPendingWrites).to.be.true;
+ const docsData = toDataMap(qrySnap);
+ expect(qrySnap.docChanges().length).to.equal(4);
+ expect(docsData).to.deep.equal({
+ doc1: { key1: 'value1' },
+ doc2: { key2: 'value2', key2b: 'value2b' },
+ doc3: { key3b: 'value3b' },
+ doc4: { key4: 'value4' }
+ });
+ });
+ });
+ });
+
+ it('get document while online with source=cache', () => {
+ const initialData = { key: 'value' };
+ return withTestDocAndInitialData(persistence, initialData, docRef => {
+ // Register a snapshot to force the data to stay in the cache and not be
+ // garbage collected.
+ docRef.onSnapshot(() => {});
+ return docRef
+ .get()
+ .then(ignored => docRef.get({ source: 'cache' }))
+ .then(doc => {
+ expect(doc.exists).to.be.true;
+ expect(doc.metadata.fromCache).to.be.true;
+ expect(doc.metadata.hasPendingWrites).to.be.false;
+ expect(doc.data()).to.deep.equal(initialData);
+ });
+ });
+ });
+
+ it('get collection while online with source=cache', () => {
+ const initialDocs = {
+ doc1: { key1: 'value1' },
+ doc2: { key2: 'value2' },
+ doc3: { key3: 'value3' }
+ };
+ return withTestCollection(persistence, initialDocs, colRef => {
+ // Register a snapshot to force the data to stay in the cache and not be
+ // garbage collected.
+ colRef.onSnapshot(() => {});
+ return colRef
+ .get()
+ .then(ignored => colRef.get({ source: 'cache' }))
+ .then(qrySnap => {
+ expect(qrySnap.metadata.fromCache).to.be.true;
+ expect(qrySnap.metadata.hasPendingWrites).to.be.false;
+ expect(qrySnap.docChanges().length).to.equal(3);
+ expect(toDataMap(qrySnap)).to.deep.equal(initialDocs);
+ });
+ });
+ });
+
+ it('get document while offline with source=cache', () => {
+ const initialData = { key: 'value' };
+
+ return withTestDocAndInitialData(persistence, initialData, docRef => {
+ // Register a snapshot to force the data to stay in the cache and not be
+ // garbage collected.
+ docRef.onSnapshot(() => {});
+ return docRef
+ .get()
+ .then(ignored => docRef.firestore.disableNetwork())
+ .then(() => docRef.get({ source: 'cache' }))
+ .then(doc => {
+ expect(doc.exists).to.be.true;
+ expect(doc.metadata.fromCache).to.be.true;
+ expect(doc.metadata.hasPendingWrites).to.be.false;
+ expect(doc.data()).to.deep.equal(initialData);
+ });
+ });
+ });
+
+ it('get collection while offline with source=cache', () => {
+ const initialDocs = {
+ doc1: { key1: 'value1' },
+ doc2: { key2: 'value2' },
+ doc3: { key3: 'value3' }
+ };
+ return withTestCollection(persistence, initialDocs, colRef => {
+ // Register a snapshot to force the data to stay in the cache and not be
+ // garbage collected.
+ colRef.onSnapshot(() => {});
+ return colRef
+ .get()
+ .then(ignored => colRef.firestore.disableNetwork())
+ .then(() => {
+ // NB: since we're offline, the returned promises won't complete
+ /* eslint-disable @typescript-eslint/no-floating-promises */
+ colRef.doc('doc2').set({ key2b: 'value2b' }, { merge: true });
+ colRef.doc('doc3').set({ key3b: 'value3b' });
+ colRef.doc('doc4').set({ key4: 'value4' });
+ /* eslint-enable @typescript-eslint/no-floating-promises */
+
+ return colRef.get({ source: 'cache' });
+ })
+ .then(qrySnap => {
+ expect(qrySnap.metadata.fromCache).to.be.true;
+ expect(qrySnap.metadata.hasPendingWrites).to.be.true;
+ const docsData = toDataMap(qrySnap);
+ expect(qrySnap.docChanges().length).to.equal(4);
+ expect(docsData).to.deep.equal({
+ doc1: { key1: 'value1' },
+ doc2: { key2: 'value2', key2b: 'value2b' },
+ doc3: { key3b: 'value3b' },
+ doc4: { key4: 'value4' }
+ });
+ });
+ });
+ });
+
+ it('get document while online with source=server', () => {
+ const initialData = { key: 'value' };
+ return withTestDocAndInitialData(persistence, initialData, docRef => {
+ return docRef.get({ source: 'server' }).then(doc => {
+ expect(doc.exists).to.be.true;
+ expect(doc.metadata.fromCache).to.be.false;
+ expect(doc.metadata.hasPendingWrites).to.be.false;
+ expect(doc.data()).to.deep.equal(initialData);
+ });
+ });
+ });
+
+ it('get collection while online with source=server', () => {
+ const initialDocs = {
+ doc1: { key1: 'value1' },
+ doc2: { key2: 'value2' },
+ doc3: { key3: 'value3' }
+ };
+ return withTestCollection(persistence, initialDocs, colRef => {
+ return colRef.get({ source: 'server' }).then(qrySnap => {
+ expect(qrySnap.metadata.fromCache).to.be.false;
+ expect(qrySnap.metadata.hasPendingWrites).to.be.false;
+ expect(qrySnap.docChanges().length).to.equal(3);
+ expect(toDataMap(qrySnap)).to.deep.equal(initialDocs);
+ });
+ });
+ });
+
+ it('get document while offline with source=server', () => {
+ const initialData = { key: 'value' };
+ return withTestDocAndInitialData(persistence, initialData, docRef => {
+ return docRef
+ .get({ source: 'server' })
+ .then(ignored => {})
+ .then(() => docRef.firestore.disableNetwork())
+ .then(() => docRef.get({ source: 'server' }))
+ .then(
+ doc => {
+ expect.fail();
+ },
+ expected => {}
+ );
+ });
+ });
+
+ it('get collection while offline with source=server', () => {
+ const initialDocs = {
+ doc1: { key1: 'value1' },
+ doc2: { key2: 'value2' },
+ doc3: { key3: 'value3' }
+ };
+ return withTestCollection(persistence, initialDocs, colRef => {
+ // force local cache of these
+ return (
+ colRef
+ .get()
+ // now go offine. Note that if persistence is disabled, this will cause
+ // the initialDocs to be garbage collected.
+ .then(ignored => colRef.firestore.disableNetwork())
+ .then(() => colRef.get({ source: 'server' }))
+ .then(
+ qrySnap => {
+ expect.fail();
+ },
+ expected => {}
+ )
+ );
+ });
+ });
+
+ it('get document while offline with different get options', () => {
+ const initialData = { key: 'value' };
+
+ return withTestDocAndInitialData(persistence, initialData, docRef => {
+ // Register a snapshot to force the data to stay in the cache and not be
+ // garbage collected.
+ docRef.onSnapshot(() => {});
+ return docRef
+ .get()
+ .then(ignored => docRef.firestore.disableNetwork())
+ .then(() => {
+ // Create an initial listener for this query (to attempt to disrupt the
+ // gets below) and wait for the listener to deliver its initial
+ // snapshot before continuing.
+ return new Promise((resolve, reject) => {
+ docRef.onSnapshot(
+ docSnap => {
+ resolve();
+ },
+ error => {
+ reject();
+ }
+ );
+ });
+ })
+ .then(() => docRef.get({ source: 'cache' }))
+ .then(doc => {
+ expect(doc.exists).to.be.true;
+ expect(doc.metadata.fromCache).to.be.true;
+ expect(doc.metadata.hasPendingWrites).to.be.false;
+ expect(doc.data()).to.deep.equal(initialData);
+ return Promise.resolve();
+ })
+ .then(() => docRef.get())
+ .then(doc => {
+ expect(doc.exists).to.be.true;
+ expect(doc.metadata.fromCache).to.be.true;
+ expect(doc.metadata.hasPendingWrites).to.be.false;
+ expect(doc.data()).to.deep.equal(initialData);
+ return Promise.resolve();
+ })
+ .then(() => docRef.get({ source: 'server' }))
+ .then(
+ doc => {
+ expect.fail();
+ },
+ expected => {}
+ );
+ });
+ });
+
+ it('get collection while offline with different get options', () => {
+ const initialDocs = {
+ doc1: { key1: 'value1' },
+ doc2: { key2: 'value2' },
+ doc3: { key3: 'value3' }
+ };
+ return withTestCollection(persistence, initialDocs, colRef => {
+ // Register a snapshot to force the data to stay in the cache and not be
+ // garbage collected.
+ colRef.onSnapshot(() => {});
+ return (
+ colRef
+ .get()
+ // now go offine. Note that if persistence is disabled, this will cause
+ // the initialDocs to be garbage collected.
+ .then(ignored => colRef.firestore.disableNetwork())
+ .then(() => {
+ // NB: since we're offline, the returned promises won't complete
+ /* eslint-disable @typescript-eslint/no-floating-promises */
+ colRef.doc('doc2').set({ key2b: 'value2b' }, { merge: true });
+ colRef.doc('doc3').set({ key3b: 'value3b' });
+ colRef.doc('doc4').set({ key4: 'value4' });
+ /* eslint-enable @typescript-eslint/no-floating-promises */
+
+ // Create an initial listener for this query (to attempt to disrupt the
+ // gets below) and wait for the listener to deliver its initial
+ // snapshot before continuing.
+ return new Promise((resolve, reject) => {
+ colRef.onSnapshot(
+ qrySnap => {
+ resolve();
+ },
+ error => {
+ reject();
+ }
+ );
+ });
+ })
+ .then(() => colRef.get({ source: 'cache' }))
+ .then(qrySnap => {
+ expect(qrySnap.metadata.fromCache).to.be.true;
+ expect(qrySnap.metadata.hasPendingWrites).to.be.true;
+ const docsData = toDataMap(qrySnap);
+ expect(qrySnap.docChanges().length).to.equal(4);
+ expect(docsData).to.deep.equal({
+ doc1: { key1: 'value1' },
+ doc2: { key2: 'value2', key2b: 'value2b' },
+ doc3: { key3b: 'value3b' },
+ doc4: { key4: 'value4' }
+ });
+ })
+ .then(() => colRef.get())
+ .then(qrySnap => {
+ expect(qrySnap.metadata.fromCache).to.be.true;
+ expect(qrySnap.metadata.hasPendingWrites).to.be.true;
+ const docsData = toDataMap(qrySnap);
+ expect(qrySnap.docChanges().length).to.equal(4);
+ expect(docsData).to.deep.equal({
+ doc1: { key1: 'value1' },
+ doc2: { key2: 'value2', key2b: 'value2b' },
+ doc3: { key3b: 'value3b' },
+ doc4: { key4: 'value4' }
+ });
+ })
+ .then(() => colRef.get({ source: 'server' }))
+ .then(
+ qrySnap => {
+ expect.fail();
+ },
+ expected => {}
+ )
+ );
+ });
+ });
+
+ it('get non existing doc while online with default get options', () => {
+ return withTestDocAndInitialData(persistence, null, docRef => {
+ return docRef.get().then(doc => {
+ expect(doc.exists).to.be.false;
+ expect(doc.metadata.fromCache).to.be.false;
+ expect(doc.metadata.hasPendingWrites).to.be.false;
+ });
+ });
+ });
+
+ it('get non existing collection while online with default get options', () => {
+ return withTestCollection(persistence, {}, colRef => {
+ return colRef.get().then(qrySnap => {
+ //expect(qrySnap.count).to.equal(0);
+ expect(qrySnap.empty).to.be.true;
+ expect(qrySnap.docChanges().length).to.equal(0);
+ expect(qrySnap.metadata.fromCache).to.be.false;
+ expect(qrySnap.metadata.hasPendingWrites).to.be.false;
+ });
+ });
+ });
+
+ it('get non existing doc while offline with default get options', () => {
+ return withTestDocAndInitialData(persistence, null, docRef => {
+ return (
+ docRef.firestore
+ .disableNetwork()
+ // Attempt to get doc. This will fail since there's nothing in cache.
+ .then(() => docRef.get())
+ .then(
+ doc => {
+ expect.fail();
+ },
+ expected => {}
+ )
+ );
+ });
+ });
+
+ // TODO(b/112267729): We should raise a fromCache=true event with a
+ // nonexistent snapshot, but because the default source goes through a normal
+ // listener, we do not.
+ // eslint-disable-next-line no-restricted-properties
+ it.skip('get deleted doc while offline with default get options', () => {
+ return withTestDocAndInitialData(persistence, null, docRef => {
+ return docRef
+ .delete()
+ .then(() => docRef.firestore.disableNetwork())
+ .then(() => docRef.get())
+ .then(doc => {
+ expect(doc.exists).to.be.false;
+ expect(doc.data()).to.be.undefined;
+ expect(doc.metadata.fromCache).to.be.true;
+ expect(doc.metadata.hasPendingWrites).to.be.false;
+ });
+ });
+ });
+
+ it('get non existing collection while offline with default get options', () => {
+ return withTestCollection(persistence, {}, colRef => {
+ return colRef.firestore
+ .disableNetwork()
+ .then(() => colRef.get())
+ .then(qrySnap => {
+ expect(qrySnap.empty).to.be.true;
+ expect(qrySnap.docChanges().length).to.equal(0);
+ expect(qrySnap.metadata.fromCache).to.be.true;
+ expect(qrySnap.metadata.hasPendingWrites).to.be.false;
+ });
+ });
+ });
+
+ it('get non existing doc while online with source=cache', () => {
+ return withTestDocAndInitialData(persistence, null, docRef => {
+ // Attempt to get doc. This will fail since there's nothing in cache.
+ return docRef.get({ source: 'cache' }).then(
+ doc => {
+ expect.fail();
+ },
+ expected => {}
+ );
+ });
+ });
+
+ it('get non existing collection while online with source=cache', () => {
+ return withTestCollection(persistence, {}, colRef => {
+ return colRef.get({ source: 'cache' }).then(qrySnap => {
+ expect(qrySnap.empty).to.be.true;
+ expect(qrySnap.docChanges().length).to.equal(0);
+ expect(qrySnap.metadata.fromCache).to.be.true;
+ expect(qrySnap.metadata.hasPendingWrites).to.be.false;
+ });
+ });
+ });
+
+ it('get non existing doc while offline with source=cache', () => {
+ return withTestDocAndInitialData(persistence, null, docRef => {
+ return (
+ docRef.firestore
+ .disableNetwork()
+ // Attempt to get doc. This will fail since there's nothing in cache.
+ .then(() => docRef.get({ source: 'cache' }))
+ .then(
+ doc => {
+ expect.fail();
+ },
+ expected => {}
+ )
+ );
+ });
+ });
+
+ // We need the deleted doc to stay in cache, so only run this with persistence.
+ // eslint-disable-next-line no-restricted-properties,
+ (persistence ? it : it.skip)(
+ 'get deleted doc while offline with source=cache',
+ () => {
+ return withTestDocAndInitialData(persistence, null, docRef => {
+ return (
+ docRef
+ .delete()
+ .then(() => docRef.firestore.disableNetwork())
+ // Should get a document with exists=false, fromCache=true
+ .then(() => docRef.get({ source: 'cache' }))
+ .then(doc => {
+ expect(doc.exists).to.be.false;
+ expect(doc.data()).to.be.undefined;
+ expect(doc.metadata.fromCache).to.be.true;
+ expect(doc.metadata.hasPendingWrites).to.be.false;
+ })
+ );
+ });
+ }
+ );
+
+ it('get non existing collection while offline with source=cache', () => {
+ return withTestCollection(persistence, {}, colRef => {
+ return colRef.firestore
+ .disableNetwork()
+ .then(() => colRef.get({ source: 'cache' }))
+ .then(qrySnap => {
+ expect(qrySnap.empty).to.be.true;
+ expect(qrySnap.docChanges().length).to.equal(0);
+ expect(qrySnap.metadata.fromCache).to.be.true;
+ expect(qrySnap.metadata.hasPendingWrites).to.be.false;
+ });
+ });
+ });
+
+ it('get non existing doc while online with source=server', () => {
+ return withTestDocAndInitialData(persistence, null, docRef => {
+ return docRef.get({ source: 'server' }).then(doc => {
+ expect(doc.exists).to.be.false;
+ expect(doc.metadata.fromCache).to.be.false;
+ expect(doc.metadata.hasPendingWrites).to.be.false;
+ });
+ });
+ });
+
+ it('get non existing collection while online with source=server', () => {
+ return withTestCollection(persistence, {}, colRef => {
+ return colRef.get({ source: 'server' }).then(qrySnap => {
+ expect(qrySnap.empty).to.be.true;
+ expect(qrySnap.docChanges().length).to.equal(0);
+ expect(qrySnap.metadata.fromCache).to.be.false;
+ expect(qrySnap.metadata.hasPendingWrites).to.be.false;
+ });
+ });
+ });
+
+ it('get non existing doc while offline with source=server', () => {
+ return withTestDocAndInitialData(persistence, null, docRef => {
+ return (
+ docRef.firestore
+ .disableNetwork()
+ // Attempt to get doc. This will fail since there's nothing in cache.
+ .then(() => docRef.get({ source: 'server' }))
+ .then(
+ doc => {
+ expect.fail();
+ },
+ expected => {}
+ )
+ );
+ });
+ });
+
+ it('get non existing collection while offline with source=server', () => {
+ return withTestCollection(persistence, {}, colRef => {
+ return colRef.firestore
+ .disableNetwork()
+ .then(() => colRef.get({ source: 'server' }))
+ .then(
+ qrySnap => {
+ expect.fail();
+ },
+ expected => {}
+ );
+ });
+ });
+});
diff --git a/packages/firestore-compat/test/numeric_transforms.test.ts b/packages/firestore-compat/test/numeric_transforms.test.ts
new file mode 100644
index 00000000000..95b7db81f65
--- /dev/null
+++ b/packages/firestore-compat/test/numeric_transforms.test.ts
@@ -0,0 +1,224 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as firestore from '@firebase/firestore-types';
+import { expect } from 'chai';
+
+import { EventsAccumulator } from './util/events_accumulator';
+import * as firebaseExport from './util/firebase_export';
+import { apiDescribe, withTestDoc } from './util/helpers';
+
+const FieldValue = firebaseExport.FieldValue;
+
+const DOUBLE_EPSILON = 0.000001;
+
+apiDescribe('Numeric Transforms:', (persistence: boolean) => {
+ // A document reference to read and write to.
+ let docRef: firestore.DocumentReference;
+
+ // Accumulator used to capture events during the test.
+ let accumulator: EventsAccumulator;
+
+ // Listener registration for a listener maintained during the course of the
+ // test.
+ let unsubscribe: () => void;
+
+ /** Writes some initialData and consumes the events generated. */
+ async function writeInitialData(
+ initialData: firestore.DocumentData
+ ): Promise {
+ await docRef.set(initialData);
+ await accumulator.awaitLocalEvent();
+ const snapshot = await accumulator.awaitRemoteEvent();
+ expect(snapshot.data()).to.deep.equal(initialData);
+ }
+
+ async function expectLocalAndRemoteValue(expectedSum: number): Promise {
+ const localSnap = await accumulator.awaitLocalEvent();
+ expect(localSnap.get('sum')).to.be.closeTo(expectedSum, DOUBLE_EPSILON);
+ const remoteSnap = await accumulator.awaitRemoteEvent();
+ expect(remoteSnap.get('sum')).to.be.closeTo(expectedSum, DOUBLE_EPSILON);
+ }
+
+ /**
+ * Wraps a test, getting a docRef and event accumulator, and cleaning them
+ * up when done.
+ */
+ async function withTestSetup(test: () => Promise): Promise {
+ await withTestDoc(persistence, async doc => {
+ docRef = doc;
+ accumulator = new EventsAccumulator();
+ unsubscribe = docRef.onSnapshot(
+ { includeMetadataChanges: true },
+ accumulator.storeEvent
+ );
+
+ // wait for initial null snapshot to avoid potential races.
+ const snapshot = await accumulator.awaitRemoteEvent();
+ expect(snapshot.exists).to.be.false;
+ await test();
+ unsubscribe();
+ });
+ }
+
+ it('create document with increment', async () => {
+ await withTestSetup(async () => {
+ await docRef.set({ sum: FieldValue.increment(1337) });
+ await expectLocalAndRemoteValue(1337);
+ });
+ });
+
+ it('merge on non-existing document with increment', async () => {
+ await withTestSetup(async () => {
+ await docRef.set({ sum: FieldValue.increment(1337) }, { merge: true });
+ await expectLocalAndRemoteValue(1337);
+ });
+ });
+
+ it('increment existing integer with integer', async () => {
+ await withTestSetup(async () => {
+ await writeInitialData({ sum: 1337 });
+ await docRef.update('sum', FieldValue.increment(1));
+ await expectLocalAndRemoteValue(1338);
+ });
+ });
+
+ it('increment existing double with double', async () => {
+ await withTestSetup(async () => {
+ await writeInitialData({ sum: 13.37 });
+ await docRef.update('sum', FieldValue.increment(0.1));
+ await expectLocalAndRemoteValue(13.47);
+ });
+ });
+
+ it('increment existing double with integer', async () => {
+ await withTestSetup(async () => {
+ await writeInitialData({ sum: 13.37 });
+ await docRef.update('sum', FieldValue.increment(1));
+ await expectLocalAndRemoteValue(14.37);
+ });
+ });
+
+ it('increment existing integer with double', async () => {
+ await withTestSetup(async () => {
+ await writeInitialData({ sum: 1337 });
+ await docRef.update('sum', FieldValue.increment(0.1));
+ await expectLocalAndRemoteValue(1337.1);
+ });
+ });
+
+ it('increment existing string with integer', async () => {
+ await withTestSetup(async () => {
+ await writeInitialData({ sum: 'overwrite' });
+ await docRef.update('sum', FieldValue.increment(1337));
+ await expectLocalAndRemoteValue(1337);
+ });
+ });
+
+ it('increment existing string with double', async () => {
+ await withTestSetup(async () => {
+ await writeInitialData({ sum: 'overwrite' });
+ await docRef.update('sum', FieldValue.increment(13.37));
+ await expectLocalAndRemoteValue(13.37);
+ });
+ });
+
+ it('increments with set() and merge:true', async () => {
+ await withTestSetup(async () => {
+ await writeInitialData({ sum: 1 });
+ await docRef.set({ sum: FieldValue.increment(1337) }, { merge: true });
+ await expectLocalAndRemoteValue(1338);
+ });
+ });
+
+ it('multiple double increments', async () => {
+ await withTestSetup(async () => {
+ await writeInitialData({ sum: 0.0 });
+
+ await docRef.firestore.disableNetwork();
+
+ /* eslint-disable @typescript-eslint/no-floating-promises */
+ docRef.update('sum', FieldValue.increment(0.1));
+ docRef.update('sum', FieldValue.increment(0.01));
+ docRef.update('sum', FieldValue.increment(0.001));
+ /* eslint-enable @typescript-eslint/no-floating-promises */
+
+ let snap = await accumulator.awaitLocalEvent();
+ expect(snap.get('sum')).to.be.closeTo(0.1, DOUBLE_EPSILON);
+ snap = await accumulator.awaitLocalEvent();
+ expect(snap.get('sum')).to.be.closeTo(0.11, DOUBLE_EPSILON);
+ snap = await accumulator.awaitLocalEvent();
+ expect(snap.get('sum')).to.be.closeTo(0.111, DOUBLE_EPSILON);
+
+ await docRef.firestore.enableNetwork();
+
+ snap = await accumulator.awaitRemoteEvent();
+ expect(snap.get('sum')).to.be.closeTo(0.111, DOUBLE_EPSILON);
+ });
+ });
+
+ it('increment twice in a batch', async () => {
+ await withTestSetup(async () => {
+ await writeInitialData({ sum: 'overwrite' });
+
+ const batch = docRef.firestore.batch();
+ batch.update(docRef, 'sum', FieldValue.increment(1));
+ batch.update(docRef, 'sum', FieldValue.increment(1));
+ await batch.commit();
+
+ await expectLocalAndRemoteValue(2);
+ });
+ });
+
+ it('increment, delete and increment in a batch', async () => {
+ await withTestSetup(async () => {
+ await writeInitialData({ sum: 'overwrite' });
+
+ const batch = docRef.firestore.batch();
+ batch.update(docRef, 'sum', FieldValue.increment(1));
+ batch.update(docRef, 'sum', FieldValue.delete());
+ batch.update(docRef, 'sum', FieldValue.increment(3));
+ await batch.commit();
+
+ await expectLocalAndRemoteValue(3);
+ });
+ });
+
+ it('increment on top of ServerTimestamp', async () => {
+ // This test stacks two pending transforms (a ServerTimestamp and an Increment transform)
+ // and reproduces the setup that was reported in
+ // https://github.com/firebase/firebase-android-sdk/issues/491
+ // In our original code, a NumericIncrementTransformOperation could cause us to decode the
+ // ServerTimestamp as part of a PatchMutation, which triggered an assertion failure.
+ await withTestSetup(async () => {
+ await docRef.firestore.disableNetwork();
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ docRef.set({ val: FieldValue.serverTimestamp() });
+ let snap = await accumulator.awaitLocalEvent();
+ expect(snap.get('val', { serverTimestamps: 'estimate' })).to.not.be.null;
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ docRef.set({ val: FieldValue.increment(1) });
+ snap = await accumulator.awaitLocalEvent();
+ expect(snap.get('val')).to.equal(1);
+
+ await docRef.firestore.enableNetwork();
+
+ snap = await accumulator.awaitRemoteEvent();
+ expect(snap.get('val')).to.equal(1);
+ });
+ });
+});
diff --git a/packages/firestore-compat/test/query.test.ts b/packages/firestore-compat/test/query.test.ts
new file mode 100644
index 00000000000..d3173357b15
--- /dev/null
+++ b/packages/firestore-compat/test/query.test.ts
@@ -0,0 +1,1216 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as firestore from '@firebase/firestore-types';
+import { expect } from 'chai';
+
+import { addEqualityMatcher } from './util/equality_matcher';
+import { EventsAccumulator } from './util/events_accumulator';
+import * as firebaseExport from './util/firebase_export';
+import {
+ apiDescribe,
+ toChangesArray,
+ toDataArray,
+ withTestCollection,
+ withTestDb
+} from './util/helpers';
+import { Deferred } from './util/promise';
+
+const Blob = firebaseExport.Blob;
+const FieldPath = firebaseExport.FieldPath;
+const GeoPoint = firebaseExport.GeoPoint;
+const Timestamp = firebaseExport.Timestamp;
+
+apiDescribe('Queries', (persistence: boolean) => {
+ addEqualityMatcher();
+
+ it('can issue limit queries', () => {
+ const testDocs = {
+ a: { k: 'a' },
+ b: { k: 'b' },
+ c: { k: 'c' }
+ };
+ return withTestCollection(persistence, testDocs, collection => {
+ return collection
+ .limit(2)
+ .get()
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([{ k: 'a' }, { k: 'b' }]);
+ });
+ });
+ });
+
+ it('cannot issue limitToLast queries without explicit order-by', () => {
+ return withTestCollection(persistence, {}, async collection => {
+ const expectedError =
+ 'limitToLast() queries require specifying at least one orderBy() clause';
+ expect(() => collection.limitToLast(2).get()).to.throw(expectedError);
+ });
+ });
+
+ it('can issue limit queries using descending sort order', () => {
+ const testDocs = {
+ a: { k: 'a', sort: 0 },
+ b: { k: 'b', sort: 1 },
+ c: { k: 'c', sort: 1 },
+ d: { k: 'd', sort: 2 }
+ };
+ return withTestCollection(persistence, testDocs, collection => {
+ return collection
+ .orderBy('sort', 'desc')
+ .limit(2)
+ .get()
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([
+ { k: 'd', sort: 2 },
+ { k: 'c', sort: 1 }
+ ]);
+ });
+ });
+ });
+
+ it('can issue limitToLast queries using descending sort order', () => {
+ const testDocs = {
+ a: { k: 'a', sort: 0 },
+ b: { k: 'b', sort: 1 },
+ c: { k: 'c', sort: 1 },
+ d: { k: 'd', sort: 2 }
+ };
+ return withTestCollection(persistence, testDocs, collection => {
+ return collection
+ .orderBy('sort', 'desc')
+ .limitToLast(2)
+ .get()
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([
+ { k: 'b', sort: 1 },
+ { k: 'a', sort: 0 }
+ ]);
+ });
+ });
+ });
+
+ it('can listen to limitToLast queries', () => {
+ const testDocs = {
+ a: { k: 'a', sort: 0 },
+ b: { k: 'b', sort: 1 },
+ c: { k: 'c', sort: 1 },
+ d: { k: 'd', sort: 2 }
+ };
+ return withTestCollection(persistence, testDocs, async collection => {
+ const storeEvent = new EventsAccumulator();
+ collection
+ .orderBy('sort', 'desc')
+ .limitToLast(2)
+ .onSnapshot(storeEvent.storeEvent);
+
+ let snapshot = await storeEvent.awaitEvent();
+ expect(toDataArray(snapshot)).to.deep.equal([
+ { k: 'b', sort: 1 },
+ { k: 'a', sort: 0 }
+ ]);
+
+ await collection.add({ k: 'e', sort: -1 });
+ snapshot = await storeEvent.awaitEvent();
+ expect(toDataArray(snapshot)).to.deep.equal([
+ { k: 'a', sort: 0 },
+ { k: 'e', sort: -1 }
+ ]);
+ });
+ });
+
+ // Two queries that mapped to the same target ID are referred to as
+ // "mirror queries". An example for a mirror query is a limitToLast()
+ // query and a limit() query that share the same backend Target ID.
+ // Since limitToLast() queries are sent to the backend with a modified
+ // orderBy() clause, they can map to the same target representation as
+ // limit() query, even if both queries appear separate to the user.
+ it('can listen/unlisten/relisten to mirror queries', () => {
+ const testDocs = {
+ a: { k: 'a', sort: 0 },
+ b: { k: 'b', sort: 1 },
+ c: { k: 'c', sort: 1 },
+ d: { k: 'd', sort: 2 }
+ };
+ return withTestCollection(persistence, testDocs, async collection => {
+ // Setup `limit` query
+ const storeLimitEvent = new EventsAccumulator();
+ let limitUnlisten = collection
+ .orderBy('sort', 'asc')
+ .limit(2)
+ .onSnapshot(storeLimitEvent.storeEvent);
+
+ // Setup mirroring `limitToLast` query
+ const storeLimitToLastEvent =
+ new EventsAccumulator();
+ let limitToLastUnlisten = collection
+ .orderBy('sort', 'desc')
+ .limitToLast(2)
+ .onSnapshot(storeLimitToLastEvent.storeEvent);
+
+ // Verify both queries get expected results.
+ let snapshot = await storeLimitEvent.awaitEvent();
+ expect(toDataArray(snapshot)).to.deep.equal([
+ { k: 'a', sort: 0 },
+ { k: 'b', sort: 1 }
+ ]);
+ snapshot = await storeLimitToLastEvent.awaitEvent();
+ expect(toDataArray(snapshot)).to.deep.equal([
+ { k: 'b', sort: 1 },
+ { k: 'a', sort: 0 }
+ ]);
+
+ // Unlisten then relisten limit query.
+ limitUnlisten();
+ limitUnlisten = collection
+ .orderBy('sort', 'asc')
+ .limit(2)
+ .onSnapshot(storeLimitEvent.storeEvent);
+
+ // Verify `limit` query still works.
+ snapshot = await storeLimitEvent.awaitEvent();
+ expect(toDataArray(snapshot)).to.deep.equal([
+ { k: 'a', sort: 0 },
+ { k: 'b', sort: 1 }
+ ]);
+
+ // Add a document that would change the result set.
+ await collection.add({ k: 'e', sort: -1 });
+
+ // Verify both queries get expected results.
+ snapshot = await storeLimitEvent.awaitEvent();
+ expect(toDataArray(snapshot)).to.deep.equal([
+ { k: 'e', sort: -1 },
+ { k: 'a', sort: 0 }
+ ]);
+ snapshot = await storeLimitToLastEvent.awaitEvent();
+ expect(toDataArray(snapshot)).to.deep.equal([
+ { k: 'a', sort: 0 },
+ { k: 'e', sort: -1 }
+ ]);
+
+ // Unlisten to limitToLast, update a doc, then relisten limitToLast.
+ limitToLastUnlisten();
+ await collection.doc('a').update({ k: 'a', sort: -2 });
+ limitToLastUnlisten = collection
+ .orderBy('sort', 'desc')
+ .limitToLast(2)
+ .onSnapshot(storeLimitToLastEvent.storeEvent);
+
+ // Verify both queries get expected results.
+ snapshot = await storeLimitEvent.awaitEvent();
+ expect(toDataArray(snapshot)).to.deep.equal([
+ { k: 'a', sort: -2 },
+ { k: 'e', sort: -1 }
+ ]);
+ snapshot = await storeLimitToLastEvent.awaitEvent();
+ expect(toDataArray(snapshot)).to.deep.equal([
+ { k: 'e', sort: -1 },
+ { k: 'a', sort: -2 }
+ ]);
+ });
+ });
+
+ it('key order is descending for descending inequality', () => {
+ const testDocs = {
+ a: {
+ foo: 42
+ },
+ b: {
+ foo: 42.0
+ },
+ c: {
+ foo: 42
+ },
+ d: {
+ foo: 21
+ },
+ e: {
+ foo: 21
+ },
+ f: {
+ foo: 66
+ },
+ g: {
+ foo: 66
+ }
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ return coll
+ .where('foo', '>', 21.0)
+ .orderBy('foo', 'desc')
+ .get()
+ .then(docs => {
+ expect(docs.docs.map(d => d.id)).to.deep.equal([
+ 'g',
+ 'f',
+ 'c',
+ 'b',
+ 'a'
+ ]);
+ });
+ });
+ });
+
+ it('can use unary filters', () => {
+ const testDocs = {
+ a: { null: null, nan: NaN },
+ b: { null: null, nan: 0 },
+ c: { null: false, nan: NaN }
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ return coll
+ .where('null', '==', null)
+ .where('nan', '==', NaN)
+ .get()
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([{ null: null, nan: NaN }]);
+ });
+ });
+ });
+
+ it('can filter on infinity', () => {
+ const testDocs = {
+ a: { inf: Infinity },
+ b: { inf: -Infinity }
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ return coll
+ .where('inf', '==', Infinity)
+ .get()
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([{ inf: Infinity }]);
+ });
+ });
+ });
+
+ it('will not get metadata only updates', () => {
+ const testDocs = { a: { v: 'a' }, b: { v: 'b' } };
+ return withTestCollection(persistence, testDocs, coll => {
+ const storeEvent = new EventsAccumulator();
+ let unlisten: (() => void) | null = null;
+ return Promise.all([
+ coll.doc('a').set({ v: 'a' }),
+ coll.doc('b').set({ v: 'b' })
+ ])
+ .then(() => {
+ unlisten = coll.onSnapshot(storeEvent.storeEvent);
+ return storeEvent.awaitEvent();
+ })
+ .then(querySnap => {
+ expect(toDataArray(querySnap)).to.deep.equal([
+ { v: 'a' },
+ { v: 'b' }
+ ]);
+ return coll.doc('a').set({ v: 'a1' });
+ })
+ .then(() => {
+ return storeEvent.awaitEvent();
+ })
+ .then(querySnap => {
+ expect(toDataArray(querySnap)).to.deep.equal([
+ { v: 'a1' },
+ { v: 'b' }
+ ]);
+ return storeEvent.assertNoAdditionalEvents();
+ })
+ .then(() => {
+ unlisten!();
+ });
+ });
+ });
+
+ it('maintains correct DocumentChange indices', async () => {
+ const testDocs = {
+ 'a': { order: 1 },
+ 'b': { order: 2 },
+ 'c': { 'order': 3 }
+ };
+ await withTestCollection(persistence, testDocs, async coll => {
+ const accumulator = new EventsAccumulator();
+ const unlisten = coll.orderBy('order').onSnapshot(accumulator.storeEvent);
+ await accumulator
+ .awaitEvent()
+ .then(querySnapshot => {
+ const changes = querySnapshot.docChanges();
+ expect(changes.length).to.equal(3);
+ verifyDocumentChange(changes[0], 'a', -1, 0, 'added');
+ verifyDocumentChange(changes[1], 'b', -1, 1, 'added');
+ verifyDocumentChange(changes[2], 'c', -1, 2, 'added');
+ })
+ .then(() => coll.doc('b').set({ order: 4 }))
+ .then(() => accumulator.awaitEvent())
+ .then(querySnapshot => {
+ const changes = querySnapshot.docChanges();
+ expect(changes.length).to.equal(1);
+ verifyDocumentChange(changes[0], 'b', 1, 2, 'modified');
+ })
+ .then(() => coll.doc('c').delete())
+ .then(() => accumulator.awaitEvent())
+ .then(querySnapshot => {
+ const changes = querySnapshot.docChanges();
+ expect(changes.length).to.equal(1);
+ verifyDocumentChange(changes[0], 'c', 1, -1, 'removed');
+ });
+
+ unlisten();
+ });
+ });
+
+ it('can listen for the same query with different options', () => {
+ const testDocs = { a: { v: 'a' }, b: { v: 'b' } };
+ return withTestCollection(persistence, testDocs, coll => {
+ const storeEvent = new EventsAccumulator();
+ const storeEventFull = new EventsAccumulator();
+ const unlisten1 = coll.onSnapshot(storeEvent.storeEvent);
+ const unlisten2 = coll.onSnapshot(
+ { includeMetadataChanges: true },
+ storeEventFull.storeEvent
+ );
+
+ return storeEvent
+ .awaitEvent()
+ .then(querySnap => {
+ expect(toDataArray(querySnap)).to.deep.equal([
+ { v: 'a' },
+ { v: 'b' }
+ ]);
+ return storeEventFull.awaitEvent();
+ })
+ .then(async querySnap => {
+ expect(toDataArray(querySnap)).to.deep.equal([
+ { v: 'a' },
+ { v: 'b' }
+ ]);
+ if (querySnap.metadata.fromCache) {
+ // We might receive an additional event if the first query snapshot
+ // was served from cache.
+ await storeEventFull.awaitEvent();
+ }
+ return coll.doc('a').set({ v: 'a1' });
+ })
+ .then(() => {
+ return storeEventFull.awaitEvents(2);
+ })
+ .then(events => {
+ // Expect two events for the write, once from latency compensation
+ // and once from the acknowledgment from the server.
+ expect(toDataArray(events[0])).to.deep.equal([
+ { v: 'a1' },
+ { v: 'b' }
+ ]);
+ expect(toDataArray(events[1])).to.deep.equal([
+ { v: 'a1' },
+ { v: 'b' }
+ ]);
+ const localResult = events[0].docs;
+ expect(localResult[0].metadata.hasPendingWrites).to.equal(true);
+ const syncedResults = events[1].docs;
+ expect(syncedResults[0].metadata.hasPendingWrites).to.equal(false);
+
+ return storeEvent.awaitEvent();
+ })
+ .then(querySnap => {
+ // Expect only one event for the write.
+ expect(toDataArray(querySnap)).to.deep.equal([
+ { v: 'a1' },
+ { v: 'b' }
+ ]);
+ return storeEvent.assertNoAdditionalEvents();
+ })
+ .then(() => {
+ storeEvent.allowAdditionalEvents();
+ return coll.doc('b').set({ v: 'b1' });
+ })
+ .then(() => {
+ return storeEvent.awaitEvent();
+ })
+ .then(querySnap => {
+ // Expect only one event from the second write
+ expect(toDataArray(querySnap)).to.deep.equal([
+ { v: 'a1' },
+ { v: 'b1' }
+ ]);
+ return storeEventFull.awaitEvents(2);
+ })
+ .then(events => {
+ // Expect 2 events from the second write.
+ expect(toDataArray(events[0])).to.deep.equal([
+ { v: 'a1' },
+ { v: 'b1' }
+ ]);
+ expect(toDataArray(events[1])).to.deep.equal([
+ { v: 'a1' },
+ { v: 'b1' }
+ ]);
+ const localResults = events[0].docs;
+ expect(localResults[1].metadata.hasPendingWrites).to.equal(true);
+ const syncedResults = events[1].docs;
+ expect(syncedResults[1].metadata.hasPendingWrites).to.equal(false);
+ return storeEvent.assertNoAdditionalEvents();
+ })
+ .then(() => {
+ return storeEventFull.assertNoAdditionalEvents();
+ })
+ .then(() => {
+ unlisten1!();
+ unlisten2!();
+ });
+ });
+ });
+
+ it('can issue queries with Dates differing in milliseconds', () => {
+ const date1 = new Date();
+ date1.setMilliseconds(0);
+ const date2 = new Date(date1.getTime());
+ date2.setMilliseconds(1);
+ const date3 = new Date(date1.getTime());
+ date3.setMilliseconds(2);
+
+ const testDocs = {
+ '1': { id: '1', date: date1 },
+ '2': { id: '2', date: date2 },
+ '3': { id: '3', date: date3 }
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ // Make sure to issue the queries in parallel
+ const docs1Promise = coll.where('date', '>', date1).get();
+ const docs2Promise = coll.where('date', '>', date2).get();
+
+ return Promise.all([docs1Promise, docs2Promise]).then(results => {
+ const docs1 = results[0];
+ const docs2 = results[1];
+
+ expect(toDataArray(docs1)).to.deep.equal([
+ { id: '2', date: Timestamp.fromDate(date2) },
+ { id: '3', date: Timestamp.fromDate(date3) }
+ ]);
+ expect(toDataArray(docs2)).to.deep.equal([
+ { id: '3', date: Timestamp.fromDate(date3) }
+ ]);
+ });
+ });
+ });
+
+ it('can listen for QueryMetadata changes', () => {
+ const testDocs = {
+ '1': { sort: 1, filter: true, key: '1' },
+ '2': { sort: 2, filter: true, key: '2' },
+ '3': { sort: 2, filter: true, key: '3' },
+ '4': { sort: 3, filter: false, key: '4' }
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ const query = coll.where('key', '<', '4');
+ const accum = new EventsAccumulator();
+ let unlisten2: () => void;
+ const unlisten1 = query.onSnapshot(result => {
+ expect(toDataArray(result)).to.deep.equal([
+ testDocs[1],
+ testDocs[2],
+ testDocs[3]
+ ]);
+ const query2 = coll.where('filter', '==', true);
+ unlisten2 = query2.onSnapshot(
+ {
+ includeMetadataChanges: true
+ },
+ accum.storeEvent
+ );
+ });
+ return accum.awaitEvents(2).then(events => {
+ const results1 = events[0];
+ const results2 = events[1];
+ expect(toDataArray(results1)).to.deep.equal([
+ testDocs[1],
+ testDocs[2],
+ testDocs[3]
+ ]);
+ expect(toDataArray(results1)).to.deep.equal(toDataArray(results2));
+ expect(results1.metadata.fromCache).to.equal(true);
+ expect(results2.metadata.fromCache).to.equal(false);
+ unlisten1();
+ unlisten2();
+ });
+ });
+ });
+
+ it('can listen for metadata changes', () => {
+ const initialDoc = {
+ foo: { a: 'b', v: 1 }
+ };
+ const modifiedDoc = {
+ foo: { a: 'b', v: 2 }
+ };
+ return withTestCollection(persistence, initialDoc, async coll => {
+ const accum = new EventsAccumulator();
+ const unlisten = coll.onSnapshot(
+ { includeMetadataChanges: true },
+ accum.storeEvent
+ );
+
+ await accum.awaitEvents(1).then(events => {
+ const results1 = events[0];
+ expect(toDataArray(results1)).to.deep.equal([initialDoc['foo']]);
+ });
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ coll.doc('foo').set(modifiedDoc['foo']);
+
+ await accum.awaitEvents(2).then(events => {
+ const results1 = events[0];
+ expect(toDataArray(results1)).to.deep.equal([modifiedDoc['foo']]);
+ expect(toChangesArray(results1)).to.deep.equal([modifiedDoc['foo']]);
+
+ const results2 = events[1];
+ expect(toDataArray(results2)).to.deep.equal([modifiedDoc['foo']]);
+ expect(toChangesArray(results2)).to.deep.equal([]);
+ expect(
+ toChangesArray(results2, { includeMetadataChanges: true })
+ ).to.deep.equal([modifiedDoc['foo']]);
+ });
+
+ unlisten();
+ });
+ });
+
+ it('can explicitly sort by document ID', () => {
+ const testDocs = {
+ a: { key: 'a' },
+ b: { key: 'b' },
+ c: { key: 'c' }
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ // Ideally this would be descending to validate it's different than
+ // the default, but that requires an extra index
+ return coll
+ .orderBy(FieldPath.documentId())
+ .get()
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([
+ testDocs['a'],
+ testDocs['b'],
+ testDocs['c']
+ ]);
+ });
+ });
+ });
+
+ it('can query by document ID', () => {
+ const testDocs = {
+ aa: { key: 'aa' },
+ ab: { key: 'ab' },
+ ba: { key: 'ba' },
+ bb: { key: 'bb' }
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ return coll
+ .where(FieldPath.documentId(), '==', 'ab')
+ .get()
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([testDocs['ab']]);
+ return coll
+ .where(FieldPath.documentId(), '>', 'aa')
+ .where(FieldPath.documentId(), '<=', 'ba')
+ .get();
+ })
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([
+ testDocs['ab'],
+ testDocs['ba']
+ ]);
+ });
+ });
+ });
+
+ it('can query by document ID using refs', () => {
+ const testDocs = {
+ aa: { key: 'aa' },
+ ab: { key: 'ab' },
+ ba: { key: 'ba' },
+ bb: { key: 'bb' }
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ return coll
+ .where(FieldPath.documentId(), '==', coll.doc('ab'))
+ .get()
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([testDocs['ab']]);
+ return coll
+ .where(FieldPath.documentId(), '>', coll.doc('aa'))
+ .where(FieldPath.documentId(), '<=', coll.doc('ba'))
+ .get();
+ })
+ .then(docs => {
+ expect(toDataArray(docs)).to.deep.equal([
+ testDocs['ab'],
+ testDocs['ba']
+ ]);
+ });
+ });
+ });
+
+ it('can query while reconnecting to network', () => {
+ return withTestCollection(persistence, /* docs= */ {}, coll => {
+ const deferred = new Deferred();
+
+ const unregister = coll.onSnapshot(
+ { includeMetadataChanges: true },
+ snapshot => {
+ if (!snapshot.empty && !snapshot.metadata.fromCache) {
+ deferred.resolve();
+ }
+ }
+ );
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ coll.firestore.disableNetwork().then(() => {
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ coll.doc().set({ a: 1 });
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ coll.firestore.enableNetwork();
+ });
+
+ return deferred.promise.then(unregister);
+ });
+ });
+
+ it('trigger with isFromCache=true when offline', () => {
+ return withTestCollection(persistence, { a: { foo: 1 } }, coll => {
+ const firestore = coll.firestore;
+ const accum = new EventsAccumulator();
+ const unregister = coll.onSnapshot(
+ { includeMetadataChanges: true },
+ accum.storeEvent
+ );
+
+ return accum
+ .awaitEvent()
+ .then(querySnap => {
+ // initial event
+ expect(querySnap.docs.map(doc => doc.data())).to.deep.equal([
+ { foo: 1 }
+ ]);
+ expect(querySnap.metadata.fromCache).to.be.false;
+ })
+ .then(() => firestore.disableNetwork())
+ .then(() => accum.awaitEvent())
+ .then(querySnap => {
+ // offline event with fromCache = true
+ expect(querySnap.metadata.fromCache).to.be.true;
+ })
+ .then(() => firestore.enableNetwork())
+ .then(() => accum.awaitEvent())
+ .then(querySnap => {
+ // back online event with fromCache = false
+ expect(querySnap.metadata.fromCache).to.be.false;
+ unregister();
+ });
+ });
+ });
+
+ it('can use != filters', async () => {
+ // These documents are ordered by value in "zip" since the '!=' filter is
+ // an inequality, which results in documents being sorted by value.
+ const testDocs = {
+ a: { zip: Number.NaN },
+ b: { zip: 91102 },
+ c: { zip: 98101 },
+ d: { zip: '98101' },
+ e: { zip: [98101] },
+ f: { zip: [98101, 98102] },
+ g: { zip: ['98101', { zip: 98101 }] },
+ h: { zip: { code: 500 } },
+ i: { code: 500 },
+ j: { zip: null }
+ };
+
+ await withTestCollection(persistence, testDocs, async coll => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let expected: { [name: string]: any } = { ...testDocs };
+ delete expected.c;
+ delete expected.i;
+ delete expected.j;
+ const snapshot = await coll.where('zip', '!=', 98101).get();
+ expect(toDataArray(snapshot)).to.deep.equal(Object.values(expected));
+
+ // With objects.
+ const snapshot2 = await coll.where('zip', '!=', { code: 500 }).get();
+ expected = { ...testDocs };
+ delete expected.h;
+ delete expected.i;
+ delete expected.j;
+ expect(toDataArray(snapshot2)).to.deep.equal(Object.values(expected));
+
+ // With null.
+ const snapshot3 = await coll.where('zip', '!=', null).get();
+ expected = { ...testDocs };
+ delete expected.i;
+ delete expected.j;
+ expect(toDataArray(snapshot3)).to.deep.equal(Object.values(expected));
+
+ // With NaN.
+ const snapshot4 = await coll.where('zip', '!=', Number.NaN).get();
+ expected = { ...testDocs };
+ delete expected.a;
+ delete expected.i;
+ delete expected.j;
+ expect(toDataArray(snapshot4)).to.deep.equal(Object.values(expected));
+ });
+ });
+
+ it('can use != filters by document ID', async () => {
+ const testDocs = {
+ aa: { key: 'aa' },
+ ab: { key: 'ab' },
+ ba: { key: 'ba' },
+ bb: { key: 'bb' }
+ };
+ await withTestCollection(persistence, testDocs, async coll => {
+ const snapshot = await coll
+ .where(FieldPath.documentId(), '!=', 'aa')
+ .get();
+
+ expect(toDataArray(snapshot)).to.deep.equal([
+ { key: 'ab' },
+ { key: 'ba' },
+ { key: 'bb' }
+ ]);
+ });
+ });
+
+ it('can use array-contains filters', async () => {
+ const testDocs = {
+ a: { array: [42] },
+ b: { array: ['a', 42, 'c'] },
+ c: { array: [41.999, '42', { a: [42] }] },
+ d: { array: [42], array2: ['bingo'] },
+ e: { array: [null] },
+ f: { array: [Number.NaN] }
+ };
+
+ await withTestCollection(persistence, testDocs, async coll => {
+ // Search for 42
+ const snapshot = await coll.where('array', 'array-contains', 42).get();
+ expect(toDataArray(snapshot)).to.deep.equal([
+ { array: [42] },
+ { array: ['a', 42, 'c'] },
+ { array: [42], array2: ['bingo'] }
+ ]);
+
+ // NOTE: The backend doesn't currently support null, NaN, objects, or
+ // arrays, so there isn't much of anything else interesting to test.
+ // With null.
+ const snapshot3 = await coll.where('zip', 'array-contains', null).get();
+ expect(toDataArray(snapshot3)).to.deep.equal([]);
+
+ // With NaN.
+ const snapshot4 = await coll
+ .where('zip', 'array-contains', Number.NaN)
+ .get();
+ expect(toDataArray(snapshot4)).to.deep.equal([]);
+ });
+ });
+
+ it('can use IN filters', async () => {
+ const testDocs = {
+ a: { zip: 98101 },
+ b: { zip: 91102 },
+ c: { zip: 98103 },
+ d: { zip: [98101] },
+ e: { zip: ['98101', { zip: 98101 }] },
+ f: { zip: { code: 500 } },
+ g: { zip: [98101, 98102] },
+ h: { zip: null },
+ i: { zip: Number.NaN }
+ };
+
+ await withTestCollection(persistence, testDocs, async coll => {
+ const snapshot = await coll
+ .where('zip', 'in', [98101, 98103, [98101, 98102]])
+ .get();
+ expect(toDataArray(snapshot)).to.deep.equal([
+ { zip: 98101 },
+ { zip: 98103 },
+ { zip: [98101, 98102] }
+ ]);
+
+ // With objects.
+ const snapshot2 = await coll.where('zip', 'in', [{ code: 500 }]).get();
+ expect(toDataArray(snapshot2)).to.deep.equal([{ zip: { code: 500 } }]);
+
+ // With null.
+ const snapshot3 = await coll.where('zip', 'in', [null]).get();
+ expect(toDataArray(snapshot3)).to.deep.equal([]);
+
+ // With null and a value.
+ const snapshot4 = await coll.where('zip', 'in', [98101, null]).get();
+ expect(toDataArray(snapshot4)).to.deep.equal([{ zip: 98101 }]);
+
+ // With NaN.
+ const snapshot5 = await coll.where('zip', 'in', [Number.NaN]).get();
+ expect(toDataArray(snapshot5)).to.deep.equal([]);
+
+ // With NaN and a value.
+ const snapshot6 = await coll
+ .where('zip', 'in', [98101, Number.NaN])
+ .get();
+ expect(toDataArray(snapshot6)).to.deep.equal([{ zip: 98101 }]);
+ });
+ });
+
+ it('can use IN filters by document ID', async () => {
+ const testDocs = {
+ aa: { key: 'aa' },
+ ab: { key: 'ab' },
+ ba: { key: 'ba' },
+ bb: { key: 'bb' }
+ };
+ await withTestCollection(persistence, testDocs, async coll => {
+ const snapshot = await coll
+ .where(FieldPath.documentId(), 'in', ['aa', 'ab'])
+ .get();
+
+ expect(toDataArray(snapshot)).to.deep.equal([
+ { key: 'aa' },
+ { key: 'ab' }
+ ]);
+ });
+ });
+
+ it('can use NOT_IN filters', async () => {
+ // These documents are ordered by value in "zip" since the 'not-in' filter is
+ // an inequality, which results in documents being sorted by value.
+ const testDocs = {
+ a: { zip: Number.NaN },
+ b: { zip: 91102 },
+ c: { zip: 98101 },
+ d: { zip: 98103 },
+ e: { zip: [98101] },
+ f: { zip: [98101, 98102] },
+ g: { zip: ['98101', { zip: 98101 }] },
+ h: { zip: { code: 500 } },
+ i: { code: 500 },
+ j: { zip: null }
+ };
+
+ await withTestCollection(persistence, testDocs, async coll => {
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ let expected: { [name: string]: any } = { ...testDocs };
+ delete expected.c;
+ delete expected.d;
+ delete expected.f;
+ delete expected.i;
+ delete expected.j;
+ const snapshot = await coll
+ .where('zip', 'not-in', [98101, 98103, [98101, 98102]])
+ .get();
+ expect(toDataArray(snapshot)).to.deep.equal(Object.values(expected));
+
+ // With objects.
+ const snapshot2 = await coll
+ .where('zip', 'not-in', [{ code: 500 }])
+ .get();
+ expected = { ...testDocs };
+ delete expected.h;
+ delete expected.i;
+ delete expected.j;
+ expect(toDataArray(snapshot2)).to.deep.equal(Object.values(expected));
+
+ // With null.
+ const snapshot3 = await coll.where('zip', 'not-in', [null]).get();
+ expect(toDataArray(snapshot3)).to.deep.equal([]);
+
+ // With NaN.
+ const snapshot4 = await coll.where('zip', 'not-in', [Number.NaN]).get();
+ expected = { ...testDocs };
+ delete expected.a;
+ delete expected.i;
+ delete expected.j;
+ expect(toDataArray(snapshot4)).to.deep.equal(Object.values(expected));
+
+ // With NaN and a number.
+ const snapshot5 = await coll
+ .where('zip', 'not-in', [Number.NaN, 98101])
+ .get();
+ expected = { ...testDocs };
+ delete expected.a;
+ delete expected.c;
+ delete expected.i;
+ delete expected.j;
+ expect(toDataArray(snapshot5)).to.deep.equal(Object.values(expected));
+ });
+ });
+
+ it('can use NOT_IN filters by document ID', async () => {
+ const testDocs = {
+ aa: { key: 'aa' },
+ ab: { key: 'ab' },
+ ba: { key: 'ba' },
+ bb: { key: 'bb' }
+ };
+ await withTestCollection(persistence, testDocs, async coll => {
+ const snapshot = await coll
+ .where(FieldPath.documentId(), 'not-in', ['aa', 'ab'])
+ .get();
+
+ expect(toDataArray(snapshot)).to.deep.equal([
+ { key: 'ba' },
+ { key: 'bb' }
+ ]);
+ });
+ });
+
+ it('can use array-contains-any filters', async () => {
+ const testDocs = {
+ a: { array: [42] },
+ b: { array: ['a', 42, 'c'] },
+ c: { array: [41.999, '42', { a: [42] }] },
+ d: { array: [42], array2: ['bingo'] },
+ e: { array: [43] },
+ f: { array: [{ a: 42 }] },
+ g: { array: 42 },
+ h: { array: [null] },
+ i: { array: [Number.NaN] }
+ };
+
+ await withTestCollection(persistence, testDocs, async coll => {
+ const snapshot = await coll
+ .where('array', 'array-contains-any', [42, 43])
+ .get();
+ expect(toDataArray(snapshot)).to.deep.equal([
+ { array: [42] },
+ { array: ['a', 42, 'c'] },
+ { array: [42], array2: ['bingo'] },
+ { array: [43] }
+ ]);
+
+ // With objects.
+ const snapshot2 = await coll
+ .where('array', 'array-contains-any', [{ a: 42 }])
+ .get();
+ expect(toDataArray(snapshot2)).to.deep.equal([{ array: [{ a: 42 }] }]);
+
+ // With null.
+ const snapshot3 = await coll
+ .where('array', 'array-contains-any', [null])
+ .get();
+ expect(toDataArray(snapshot3)).to.deep.equal([]);
+
+ // With null and a value.
+ const snapshot4 = await coll
+ .where('array', 'array-contains-any', [43, null])
+ .get();
+ expect(toDataArray(snapshot4)).to.deep.equal([{ array: [43] }]);
+
+ // With NaN.
+ const snapshot5 = await coll
+ .where('array', 'array-contains-any', [Number.NaN])
+ .get();
+ expect(toDataArray(snapshot5)).to.deep.equal([]);
+
+ // With NaN and a value.
+ const snapshot6 = await coll
+ .where('array', 'array-contains-any', [43, Number.NaN])
+ .get();
+ expect(toDataArray(snapshot6)).to.deep.equal([{ array: [43] }]);
+ });
+ });
+
+ it('can query collection groups', async () => {
+ await withTestDb(persistence, async db => {
+ // Use .doc() to get a random collection group name to use but ensure it starts with 'b' for
+ // predictable ordering.
+ const collectionGroup = 'b' + db.collection('foo').doc().id;
+
+ const docPaths = [
+ `abc/123/${collectionGroup}/cg-doc1`,
+ `abc/123/${collectionGroup}/cg-doc2`,
+ `${collectionGroup}/cg-doc3`,
+ `${collectionGroup}/cg-doc4`,
+ `def/456/${collectionGroup}/cg-doc5`,
+ `${collectionGroup}/virtual-doc/nested-coll/not-cg-doc`,
+ `x${collectionGroup}/not-cg-doc`,
+ `${collectionGroup}x/not-cg-doc`,
+ `abc/123/${collectionGroup}x/not-cg-doc`,
+ `abc/123/x${collectionGroup}/not-cg-doc`,
+ `abc/${collectionGroup}`
+ ];
+ const batch = db.batch();
+ for (const docPath of docPaths) {
+ batch.set(db.doc(docPath), { x: 1 });
+ }
+ await batch.commit();
+
+ const querySnapshot = await db.collectionGroup(collectionGroup).get();
+ expect(querySnapshot.docs.map(d => d.id)).to.deep.equal([
+ 'cg-doc1',
+ 'cg-doc2',
+ 'cg-doc3',
+ 'cg-doc4',
+ 'cg-doc5'
+ ]);
+ });
+ });
+
+ it('can query collection groups with startAt / endAt by arbitrary documentId', async () => {
+ await withTestDb(persistence, async db => {
+ // Use .doc() to get a random collection group name to use but ensure it starts with 'b' for
+ // predictable ordering.
+ const collectionGroup = 'b' + db.collection('foo').doc().id;
+
+ const docPaths = [
+ `a/a/${collectionGroup}/cg-doc1`,
+ `a/b/a/b/${collectionGroup}/cg-doc2`,
+ `a/b/${collectionGroup}/cg-doc3`,
+ `a/b/c/d/${collectionGroup}/cg-doc4`,
+ `a/c/${collectionGroup}/cg-doc5`,
+ `${collectionGroup}/cg-doc6`,
+ `a/b/nope/nope`
+ ];
+ const batch = db.batch();
+ for (const docPath of docPaths) {
+ batch.set(db.doc(docPath), { x: 1 });
+ }
+ await batch.commit();
+
+ let querySnapshot = await db
+ .collectionGroup(collectionGroup)
+ .orderBy(FieldPath.documentId())
+ .startAt(`a/b`)
+ .endAt('a/b0')
+ .get();
+ expect(querySnapshot.docs.map(d => d.id)).to.deep.equal([
+ 'cg-doc2',
+ 'cg-doc3',
+ 'cg-doc4'
+ ]);
+
+ querySnapshot = await db
+ .collectionGroup(collectionGroup)
+ .orderBy(FieldPath.documentId())
+ .startAfter('a/b')
+ .endBefore(`a/b/${collectionGroup}/cg-doc3`)
+ .get();
+ expect(querySnapshot.docs.map(d => d.id)).to.deep.equal(['cg-doc2']);
+ });
+ });
+
+ it('can query collection groups with where filters on arbitrary documentId', async () => {
+ await withTestDb(persistence, async db => {
+ // Use .doc() to get a random collection group name to use but ensure it starts with 'b' for
+ // predictable ordering.
+ const collectionGroup = 'b' + db.collection('foo').doc().id;
+
+ const docPaths = [
+ `a/a/${collectionGroup}/cg-doc1`,
+ `a/b/a/b/${collectionGroup}/cg-doc2`,
+ `a/b/${collectionGroup}/cg-doc3`,
+ `a/b/c/d/${collectionGroup}/cg-doc4`,
+ `a/c/${collectionGroup}/cg-doc5`,
+ `${collectionGroup}/cg-doc6`,
+ `a/b/nope/nope`
+ ];
+ const batch = db.batch();
+ for (const docPath of docPaths) {
+ batch.set(db.doc(docPath), { x: 1 });
+ }
+ await batch.commit();
+
+ let querySnapshot = await db
+ .collectionGroup(collectionGroup)
+ .where(FieldPath.documentId(), '>=', `a/b`)
+ .where(FieldPath.documentId(), '<=', 'a/b0')
+ .get();
+ expect(querySnapshot.docs.map(d => d.id)).to.deep.equal([
+ 'cg-doc2',
+ 'cg-doc3',
+ 'cg-doc4'
+ ]);
+
+ querySnapshot = await db
+ .collectionGroup(collectionGroup)
+ .where(FieldPath.documentId(), '>', `a/b`)
+ .where(FieldPath.documentId(), '<', `a/b/${collectionGroup}/cg-doc3`)
+ .get();
+ expect(querySnapshot.docs.map(d => d.id)).to.deep.equal(['cg-doc2']);
+ });
+ });
+
+ it('can query custom types', () => {
+ return withTestCollection(persistence, {}, async ref => {
+ const data = {
+ ref: ref.firestore.doc('f/c'),
+ geoPoint: new GeoPoint(0, 0),
+ buffer: Blob.fromBase64String('Zm9v'),
+ time: Timestamp.now(),
+ array: [
+ ref.firestore.doc('f/c'),
+ new GeoPoint(0, 0),
+ Blob.fromBase64String('Zm9v'),
+ Timestamp.now()
+ ]
+ };
+ await ref.add({ data });
+
+ // In https://github.com/firebase/firebase-js-sdk/issues/1524, a
+ // customer was not able to unlisten from a query that contained a
+ // nested object with a DocumentReference. The cause of it was that our
+ // serialization of nested references via JSON.stringify() was different
+ // for Queries created via the API layer versus Queries read from
+ // persistence. To simulate this issue, we have to listen and unlisten
+ // to the same query twice.
+ const query = ref.where('data', '==', data);
+
+ for (let i = 0; i < 2; ++i) {
+ const deferred = new Deferred();
+ const unsubscribe = query.onSnapshot(snapshot => {
+ expect(snapshot.size).to.equal(1);
+ deferred.resolve();
+ });
+ await deferred.promise;
+ unsubscribe();
+ }
+ });
+ });
+
+ it('can use filter with nested field', () => {
+ // Reproduces https://github.com/firebase/firebase-js-sdk/issues/2204
+ const testDocs = {
+ a: {},
+ b: { map: {} },
+ c: { map: { nested: {} } },
+ d: { map: { nested: 'foo' } }
+ };
+
+ return withTestCollection(persistence, testDocs, async coll => {
+ await coll.get(); // Populate the cache
+ const snapshot = await coll.where('map.nested', '==', 'foo').get();
+ expect(toDataArray(snapshot)).to.deep.equal([{ map: { nested: 'foo' } }]);
+ });
+ });
+});
+
+function verifyDocumentChange(
+ change: firestore.DocumentChange,
+ id: string,
+ oldIndex: number,
+ newIndex: number,
+ type: firestore.DocumentChangeType
+): void {
+ expect(change.doc.id).to.equal(id);
+ expect(change.type).to.equal(type);
+ expect(change.oldIndex).to.equal(oldIndex);
+ expect(change.newIndex).to.equal(newIndex);
+}
diff --git a/packages/firestore-compat/test/server_timestamp.test.ts b/packages/firestore-compat/test/server_timestamp.test.ts
new file mode 100644
index 00000000000..e5801eb6b08
--- /dev/null
+++ b/packages/firestore-compat/test/server_timestamp.test.ts
@@ -0,0 +1,299 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as firestore from '@firebase/firestore-types';
+import { expect } from 'chai';
+
+import { EventsAccumulator } from './util/events_accumulator';
+import * as firebaseExport from './util/firebase_export';
+import { apiDescribe, withTestDoc } from './util/helpers';
+
+// Allow custom types for testing.
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+type AnyTestData = any;
+
+const Timestamp = firebaseExport.Timestamp;
+const FieldValue = firebaseExport.FieldValue;
+
+apiDescribe('Server Timestamps', (persistence: boolean) => {
+ // Data written in tests via set().
+ const setData = {
+ a: 42,
+ when: FieldValue.serverTimestamp(),
+ deep: { when: FieldValue.serverTimestamp() }
+ };
+
+ // base and update data used for update() tests.
+ const initialData = { a: 42 };
+ const updateData = {
+ when: FieldValue.serverTimestamp(),
+ deep: { when: FieldValue.serverTimestamp() }
+ };
+
+ // A document reference to read and write to.
+ let docRef: firestore.DocumentReference;
+
+ // Accumulator used to capture events during the test.
+ let accumulator: EventsAccumulator;
+
+ // Listener registration for a listener maintained during the course of the
+ // test.
+ let unsubscribe: () => void;
+
+ // Returns the expected data, with an arbitrary timestamp substituted in.
+ function expectedDataWithTimestamp(timestamp: object | null): AnyTestData {
+ return { a: 42, when: timestamp, deep: { when: timestamp } };
+ }
+
+ /** Writes initialData and waits for the corresponding snapshot. */
+ function writeInitialData(): Promise {
+ return docRef
+ .set(initialData)
+ .then(() => accumulator.awaitEvent())
+ .then(initialDataSnap => {
+ expect(initialDataSnap.data()).to.deep.equal(initialData);
+ });
+ }
+
+ /** Verifies a snapshot containing setData but with resolved server timestamps. */
+ function verifyTimestampsAreResolved(snap: firestore.DocumentSnapshot): void {
+ expect(snap.exists).to.equal(true);
+ const when = snap.get('when');
+ expect(when).to.be.an.instanceof(Timestamp);
+ // Tolerate up to 60 seconds of clock skew between client and server
+ // since web tests may run in a Windows VM with a sloppy clock.
+ const delta = 60;
+ expect(Math.abs(when.toDate().getTime() - Date.now())).to.be.lessThan(
+ delta * 1000
+ );
+
+ // Validate the rest of the document.
+ expect(snap.data()).to.deep.equal(expectedDataWithTimestamp(when));
+ }
+
+ /** Verifies a snapshot containing setData but with null for the timestamps. */
+ function verifyTimestampsAreNull(snap: firestore.DocumentSnapshot): void {
+ expect(snap.exists).to.equal(true);
+ expect(snap.data()).to.deep.equal(expectedDataWithTimestamp(null));
+ }
+
+ /** Verifies a snapshot containing setData but with local estimates for server timestamps. */
+ function verifyTimestampsAreEstimates(
+ snap: firestore.DocumentSnapshot
+ ): void {
+ expect(snap.exists).to.equal(true);
+ const when = snap.get('when', { serverTimestamps: 'estimate' });
+ expect(when).to.be.an.instanceof(Timestamp);
+ // Validate the rest of the document.
+ expect(snap.data({ serverTimestamps: 'estimate' })).to.deep.equal(
+ expectedDataWithTimestamp(when)
+ );
+ }
+
+ /**
+ * Wraps a test, getting a docRef and event accumulator, and cleaning them
+ * up when done.
+ */
+ function withTestSetup(test: () => Promise): Promise {
+ return withTestDoc(persistence, doc => {
+ // Set variables for use during test.
+ docRef = doc;
+
+ accumulator = new EventsAccumulator();
+ unsubscribe = docRef.onSnapshot(
+ { includeMetadataChanges: true },
+ accumulator.storeEvent
+ );
+
+ // wait for initial null snapshot to avoid potential races.
+ return accumulator
+ .awaitEvent()
+ .then(docSnap => {
+ expect(docSnap.exists).to.equal(false);
+ })
+ .then(() => test())
+ .then(() => {
+ unsubscribe();
+ });
+ });
+ }
+
+ it('work via set()', () => {
+ return withTestSetup(() => {
+ return docRef
+ .set(setData)
+ .then(() => accumulator.awaitLocalEvent())
+ .then(snapshot => verifyTimestampsAreNull(snapshot))
+ .then(() => accumulator.awaitRemoteEvent())
+ .then(snapshot => verifyTimestampsAreResolved(snapshot));
+ });
+ });
+
+ it('work via update()', () => {
+ return withTestSetup(() => {
+ return writeInitialData()
+ .then(() => docRef.update(updateData))
+ .then(() => accumulator.awaitLocalEvent())
+ .then(snapshot => verifyTimestampsAreNull(snapshot))
+ .then(() => accumulator.awaitRemoteEvent())
+ .then(snapshot => verifyTimestampsAreResolved(snapshot));
+ });
+ });
+
+ it('work via transaction set()', () => {
+ return withTestSetup(() => {
+ return docRef.firestore
+ .runTransaction(async txn => {
+ txn.set(docRef, setData);
+ })
+ .then(() => accumulator.awaitRemoteEvent())
+ .then(snapshot => verifyTimestampsAreResolved(snapshot));
+ });
+ });
+
+ it('work via transaction update()', () => {
+ return withTestSetup(() => {
+ return writeInitialData()
+ .then(() => accumulator.awaitRemoteEvent())
+ .then(() =>
+ docRef.firestore.runTransaction(async txn => {
+ txn.update(docRef, updateData);
+ })
+ )
+ .then(() => accumulator.awaitRemoteEvent())
+ .then(snapshot => verifyTimestampsAreResolved(snapshot));
+ });
+ });
+
+ it('can return estimated value', () => {
+ return withTestSetup(() => {
+ return writeInitialData()
+ .then(() => docRef.update(updateData))
+ .then(() => accumulator.awaitLocalEvent())
+ .then(snapshot => verifyTimestampsAreEstimates(snapshot));
+ });
+ });
+
+ it('can return previous value of different type', () => {
+ return withTestSetup(() => {
+ return writeInitialData()
+ .then(() =>
+ // Change field 'a' from a number type to a server timestamp.
+ docRef.update('a', FieldValue.serverTimestamp())
+ )
+ .then(() => accumulator.awaitLocalEvent())
+ .then(snapshot => {
+ // Verify that we can still obtain the number.
+ expect(snapshot.get('a', { serverTimestamps: 'previous' })).to.equal(
+ 42
+ );
+ });
+ });
+ });
+
+ it('can return previous value through consecutive updates', () => {
+ return withTestSetup(() => {
+ return writeInitialData()
+ .then(() => docRef.firestore.disableNetwork())
+ .then(() => {
+ // We set up two consecutive writes with server timestamps.
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ docRef.update('a', FieldValue.serverTimestamp());
+ // include b=1 to ensure there's a change resulting in a new snapshot.
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ docRef.update('a', FieldValue.serverTimestamp(), 'b', 1);
+ return accumulator.awaitLocalEvents(2);
+ })
+ .then(snapshots => {
+ // Both snapshot use the initial value (42) as the previous value.
+ expect(
+ snapshots[0].get('a', { serverTimestamps: 'previous' })
+ ).to.equal(42);
+ expect(
+ snapshots[1].get('a', { serverTimestamps: 'previous' })
+ ).to.equal(42);
+ return docRef.firestore.enableNetwork();
+ })
+ .then(() => accumulator.awaitRemoteEvent())
+ .then(remoteSnapshot => {
+ expect(remoteSnapshot.get('a')).to.be.an.instanceof(Timestamp);
+ });
+ });
+ });
+
+ it('uses previous value from local mutation', () => {
+ return withTestSetup(() => {
+ return writeInitialData()
+ .then(() => docRef.firestore.disableNetwork())
+ .then(() => {
+ // We set up three consecutive writes.
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ docRef.update('a', FieldValue.serverTimestamp());
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ docRef.update('a', 1337);
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ docRef.update('a', FieldValue.serverTimestamp());
+ return accumulator.awaitLocalEvents(3);
+ })
+ .then(snapshots => {
+ // The first snapshot uses the initial value (42) as the previous value.
+ expect(
+ snapshots[0].get('a', { serverTimestamps: 'previous' })
+ ).to.equal(42);
+ // The third snapshot uses the intermediate value as the previous value.
+ expect(
+ snapshots[2].get('a', { serverTimestamps: 'previous' })
+ ).to.equal(1337);
+ return docRef.firestore.enableNetwork();
+ })
+ .then(() => accumulator.awaitRemoteEvent())
+ .then(remoteSnapshot => {
+ expect(remoteSnapshot.get('a')).to.be.an.instanceof(Timestamp);
+ });
+ });
+ });
+
+ it('fail via update() on nonexistent document.', () => {
+ return withTestSetup(() => {
+ return docRef.update(updateData).then(
+ () => {
+ return Promise.reject('Should not have succeeded!');
+ },
+ (error: firestore.FirestoreError) => {
+ expect(error.code).to.equal('not-found');
+ }
+ );
+ });
+ });
+
+ it('fail via transaction update() on nonexistent document.', () => {
+ return withTestSetup(() => {
+ return docRef.firestore
+ .runTransaction(async txn => {
+ txn.update(docRef, updateData);
+ })
+ .then(
+ () => {
+ return Promise.reject('Should not have succeeded!');
+ },
+ (error: firestore.FirestoreError) => {
+ expect(error.code).to.equal('not-found');
+ }
+ );
+ });
+ });
+});
diff --git a/packages/firestore-compat/test/smoke.test.ts b/packages/firestore-compat/test/smoke.test.ts
new file mode 100644
index 00000000000..dd8ab8474fa
--- /dev/null
+++ b/packages/firestore-compat/test/smoke.test.ts
@@ -0,0 +1,169 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as firestore from '@firebase/firestore-types';
+import { expect } from 'chai';
+
+import { EventsAccumulator } from './util/events_accumulator';
+import * as integrationHelpers from './util/helpers';
+
+const apiDescribe = integrationHelpers.apiDescribe;
+
+apiDescribe('Smoke Test', (persistence: boolean) => {
+ it('can write a single document', () => {
+ return integrationHelpers.withTestDoc(persistence, ref => {
+ return ref.set({
+ name: 'Patryk',
+ message: 'We are actually writing data!'
+ });
+ });
+ });
+
+ it('can read a written document', () => {
+ return integrationHelpers.withTestDoc(persistence, ref => {
+ const data = {
+ name: 'Patryk',
+ message: 'We are actually writing data!'
+ };
+ return ref
+ .set(data)
+ .then(() => {
+ return ref.get();
+ })
+ .then((doc: firestore.DocumentSnapshot) => {
+ expect(doc.data()).to.deep.equal(data);
+ });
+ });
+ });
+
+ it('can read a written document with DocumentKey', () => {
+ return integrationHelpers.withTestDoc(persistence, ref1 => {
+ const ref2 = ref1.firestore.collection('users').doc();
+ const data = { user: ref2, message: 'We are writing data' };
+ return ref2.set({ name: 'patryk' }).then(() => {
+ return ref1
+ .set(data)
+ .then(() => {
+ return ref1.get();
+ })
+ .then((doc: firestore.DocumentSnapshot) => {
+ const recv = doc.data()!;
+ expect(recv['message']).to.deep.equal(data.message);
+ const user = recv['user'];
+ // Make sure it looks like a DocumentRef.
+ expect(user.set).to.be.an.instanceof(Function);
+ expect(user.onSnapshot).to.be.an.instanceof(Function);
+ expect(user.id).to.deep.equal(ref2.id);
+ });
+ });
+ });
+ });
+
+ it('will fire local and remote events', () => {
+ return integrationHelpers.withTestDbs(
+ persistence,
+ 2,
+ ([reader, writer]) => {
+ const readerRef = reader.collection('rooms/firestore/messages').doc();
+ const writerRef = writer.doc(readerRef.path);
+ const data = {
+ name: 'Patryk',
+ message: 'We are actually writing data!'
+ };
+
+ const accum = new EventsAccumulator();
+ return writerRef.set(data).then(() => {
+ const unlisten = readerRef.onSnapshot(accum.storeEvent);
+ return accum
+ .awaitEvent()
+ .then(docSnap => {
+ expect(docSnap.exists).to.equal(true);
+ expect(docSnap.data()).to.deep.equal(data);
+ })
+ .then(() => unlisten());
+ });
+ }
+ );
+ });
+
+ it('will fire value events for empty collections', () => {
+ return integrationHelpers.withTestCollection(
+ persistence,
+ {},
+ collection => {
+ const accum = new EventsAccumulator();
+ const unlisten = collection.onSnapshot(accum.storeEvent);
+ return accum
+ .awaitEvent()
+ .then(querySnap => {
+ expect(querySnap.empty).to.equal(true);
+ expect(querySnap.size).to.equal(0);
+ expect(querySnap.docs.length).to.equal(0);
+ })
+ .then(() => unlisten());
+ }
+ );
+ });
+
+ it('can get collection query', () => {
+ const testDocs = {
+ '1': {
+ name: 'Patryk',
+ message: 'We are actually writing data!'
+ },
+ '2': { name: 'Gil', message: 'Yep!' },
+ '3': { name: 'Jonny', message: 'Crazy!' }
+ };
+ return integrationHelpers.withTestCollection(persistence, testDocs, ref => {
+ return ref.get().then(result => {
+ expect(result.empty).to.equal(false);
+ expect(result.size).to.equal(3);
+ expect(integrationHelpers.toDataArray(result)).to.deep.equal([
+ testDocs[1],
+ testDocs[2],
+ testDocs[3]
+ ]);
+ });
+ });
+ });
+
+ // TODO (b/33691136): temporarily disable failed test
+ // This broken because it requires a composite index on filter,sort
+ // eslint-disable-next-line no-restricted-properties
+ it.skip('can query by field and use order by', () => {
+ const testDocs = {
+ '1': { sort: 1, filter: true, key: '1' },
+ '2': { sort: 2, filter: true, key: '2' },
+ '3': { sort: 2, filter: true, key: '3' },
+ '4': { sort: 3, filter: false, key: '4' }
+ };
+ return integrationHelpers.withTestCollection(
+ persistence,
+ testDocs,
+ coll => {
+ const query = coll.where('filter', '==', true).orderBy('sort', 'desc');
+ return query.get().then(result => {
+ expect(integrationHelpers.toDataArray(result)).to.deep.equal([
+ testDocs[2],
+ testDocs[3],
+ testDocs[1]
+ ]);
+ });
+ }
+ );
+ });
+});
diff --git a/packages/firestore-compat/test/transactions.test.ts b/packages/firestore-compat/test/transactions.test.ts
new file mode 100644
index 00000000000..5151d672b0a
--- /dev/null
+++ b/packages/firestore-compat/test/transactions.test.ts
@@ -0,0 +1,663 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as firestore from '@firebase/firestore-types';
+import { expect } from 'chai';
+
+import * as firebaseExport from './util/firebase_export';
+import * as integrationHelpers from './util/helpers';
+
+const FieldPath = firebaseExport.FieldPath;
+
+const apiDescribe = integrationHelpers.apiDescribe;
+apiDescribe('Database transactions', (persistence: boolean) => {
+ type TransactionStage = (
+ transaction: firestore.Transaction,
+ docRef: firestore.DocumentReference
+ ) => void;
+
+ /**
+ * The transaction stages that follow are postfixed by numbers to indicate the
+ * calling order. For example, calling `set1()` followed by `set2()` should
+ * result in the document being set to the value specified by `set2()`.
+ */
+ async function delete1(
+ transaction: firestore.Transaction,
+ docRef: firestore.DocumentReference
+ ): Promise {
+ transaction.delete(docRef);
+ }
+
+ async function update1(
+ transaction: firestore.Transaction,
+ docRef: firestore.DocumentReference
+ ): Promise {
+ transaction.update(docRef, { foo: 'bar1' });
+ }
+
+ async function update2(
+ transaction: firestore.Transaction,
+ docRef: firestore.DocumentReference
+ ): Promise {
+ transaction.update(docRef, { foo: 'bar2' });
+ }
+
+ async function set1(
+ transaction: firestore.Transaction,
+ docRef: firestore.DocumentReference
+ ): Promise {
+ transaction.set(docRef, { foo: 'bar1' });
+ }
+
+ async function set2(
+ transaction: firestore.Transaction,
+ docRef: firestore.DocumentReference
+ ): Promise {
+ transaction.set(docRef, { foo: 'bar2' });
+ }
+
+ async function get(
+ transaction: firestore.Transaction,
+ docRef: firestore.DocumentReference
+ ): Promise {
+ await transaction.get(docRef);
+ }
+
+ /**
+ * Used for testing that all possible combinations of executing transactions
+ * result in the desired document value or error.
+ *
+ * `run()`, `withExistingDoc()`, and `withNonexistentDoc()` don't actually do
+ * anything except assign variables into the TransactionTester.
+ *
+ * `expectDoc()`, `expectNoDoc()`, and `expectError()` will trigger the
+ * transaction to run and assert that the end result matches the input.
+ */
+ class TransactionTester {
+ constructor(readonly db: firestore.FirebaseFirestore) {}
+
+ private docRef!: firestore.DocumentReference;
+ private fromExistingDoc: boolean = false;
+ private stages: TransactionStage[] = [];
+
+ withExistingDoc(): this {
+ this.fromExistingDoc = true;
+ return this;
+ }
+
+ withNonexistentDoc(): this {
+ this.fromExistingDoc = false;
+ return this;
+ }
+
+ run(...stages: TransactionStage[]): this {
+ this.stages = stages;
+ return this;
+ }
+
+ async expectDoc(expected: object): Promise {
+ try {
+ await this.prepareDoc();
+ await this.runTransaction();
+ const snapshot = await this.docRef.get();
+ expect(snapshot.exists).to.equal(true);
+ expect(snapshot.data()).to.deep.equal(expected);
+ } catch (err) {
+ expect.fail(
+ 'Expected the sequence (' +
+ this.listStages(this.stages) +
+ ') to succeed, but got ' +
+ err
+ );
+ }
+ this.cleanupTester();
+ }
+
+ async expectNoDoc(): Promise {
+ try {
+ await this.prepareDoc();
+ await this.runTransaction();
+ const snapshot = await this.docRef.get();
+ expect(snapshot.exists).to.equal(false);
+ } catch (err) {
+ expect.fail(
+ 'Expected the sequence (' +
+ this.listStages(this.stages) +
+ ') to succeed, but got ' +
+ err
+ );
+ }
+ this.cleanupTester();
+ }
+
+ async expectError(expected: string): Promise {
+ let succeeded = false;
+ try {
+ await this.prepareDoc();
+ await this.runTransaction();
+ succeeded = true;
+ } catch (err) {
+ expect((err as firestore.FirestoreError).code).to.equal(expected);
+ }
+ if (succeeded) {
+ expect.fail(
+ 'Expected the sequence (' +
+ this.listStages(this.stages) +
+ ') to fail with the error ' +
+ expected
+ );
+ }
+ this.cleanupTester();
+ }
+
+ private async prepareDoc(): Promise {
+ this.docRef = this.db.collection('tester-docref').doc();
+ if (this.fromExistingDoc) {
+ await this.docRef.set({ foo: 'bar0' });
+ }
+ }
+
+ private async runTransaction(): Promise {
+ await this.db.runTransaction(async transaction => {
+ for (const stage of this.stages) {
+ await stage(transaction, this.docRef);
+ }
+ });
+ }
+
+ private cleanupTester(): void {
+ this.stages = [];
+ // Set the docRef to something else to lose the original reference.
+ this.docRef = this.db.collection('reset').doc();
+ }
+
+ private listStages(stages: TransactionStage[]): string {
+ const seqList: string[] = [];
+ for (const stage of stages) {
+ if (stage === delete1) {
+ seqList.push('delete');
+ } else if (stage === update1 || stage === update2) {
+ seqList.push('update');
+ } else if (stage === set1 || stage === set2) {
+ seqList.push('set');
+ } else if (stage === get) {
+ seqList.push('get');
+ } else {
+ throw new Error('Stage not recognized.');
+ }
+ }
+ return seqList.join(', ');
+ }
+ }
+
+ it('runs transactions after getting existing document', async () => {
+ return integrationHelpers.withTestDb(persistence, async db => {
+ const tt = new TransactionTester(db);
+
+ await tt.withExistingDoc().run(get, delete1, delete1).expectNoDoc();
+ await tt
+ .withExistingDoc()
+ .run(get, delete1, update2)
+ .expectError('invalid-argument');
+ await tt
+ .withExistingDoc()
+ .run(get, delete1, set2)
+ .expectDoc({ foo: 'bar2' });
+
+ await tt.withExistingDoc().run(get, update1, delete1).expectNoDoc();
+ await tt
+ .withExistingDoc()
+ .run(get, update1, update2)
+ .expectDoc({ foo: 'bar2' });
+ await tt
+ .withExistingDoc()
+ .run(get, update1, set2)
+ .expectDoc({ foo: 'bar2' });
+
+ await tt.withExistingDoc().run(get, set1, delete1).expectNoDoc();
+ await tt
+ .withExistingDoc()
+ .run(get, set1, update2)
+ .expectDoc({ foo: 'bar2' });
+ await tt
+ .withExistingDoc()
+ .run(get, set1, set2)
+ .expectDoc({ foo: 'bar2' });
+ });
+ });
+
+ it('runs transactions after getting non-existent document', async () => {
+ return integrationHelpers.withTestDb(persistence, async db => {
+ const tt = new TransactionTester(db);
+
+ await tt.withNonexistentDoc().run(get, delete1, delete1).expectNoDoc();
+ await tt
+ .withNonexistentDoc()
+ .run(get, delete1, update2)
+ .expectError('invalid-argument');
+ await tt
+ .withNonexistentDoc()
+ .run(get, delete1, set2)
+ .expectDoc({ foo: 'bar2' });
+
+ await tt
+ .withNonexistentDoc()
+ .run(get, update1, delete1)
+ .expectError('invalid-argument');
+ await tt
+ .withNonexistentDoc()
+ .run(get, update1, update2)
+ .expectError('invalid-argument');
+ await tt
+ .withNonexistentDoc()
+ .run(get, update1, set1)
+ .expectError('invalid-argument');
+
+ await tt.withNonexistentDoc().run(get, set1, delete1).expectNoDoc();
+ await tt
+ .withNonexistentDoc()
+ .run(get, set1, update2)
+ .expectDoc({ foo: 'bar2' });
+ await tt
+ .withNonexistentDoc()
+ .run(get, set1, set2)
+ .expectDoc({ foo: 'bar2' });
+ });
+ });
+
+ it('runs transactions on existing document', async () => {
+ return integrationHelpers.withTestDb(persistence, async db => {
+ const tt = new TransactionTester(db);
+
+ await tt.withExistingDoc().run(delete1, delete1).expectNoDoc();
+ await tt
+ .withExistingDoc()
+ .run(delete1, update2)
+ .expectError('invalid-argument');
+ await tt.withExistingDoc().run(delete1, set2).expectDoc({ foo: 'bar2' });
+
+ await tt.withExistingDoc().run(update1, delete1).expectNoDoc();
+ await tt
+ .withExistingDoc()
+ .run(update1, update2)
+ .expectDoc({ foo: 'bar2' });
+ await tt.withExistingDoc().run(update1, set2).expectDoc({ foo: 'bar2' });
+
+ await tt.withExistingDoc().run(set1, delete1).expectNoDoc();
+ await tt.withExistingDoc().run(set1, update2).expectDoc({ foo: 'bar2' });
+ await tt.withExistingDoc().run(set1, set2).expectDoc({ foo: 'bar2' });
+ });
+ });
+
+ it('runs transactions on non-existent document', async () => {
+ return integrationHelpers.withTestDb(persistence, async db => {
+ const tt = new TransactionTester(db);
+
+ await tt.withNonexistentDoc().run(delete1, delete1).expectNoDoc();
+ await tt
+ .withNonexistentDoc()
+ .run(delete1, update2)
+ .expectError('invalid-argument');
+ await tt
+ .withNonexistentDoc()
+ .run(delete1, set2)
+ .expectDoc({ foo: 'bar2' });
+
+ await tt
+ .withNonexistentDoc()
+ .run(update1, delete1)
+ .expectError('not-found');
+ await tt
+ .withNonexistentDoc()
+ .run(update1, update2)
+ .expectError('not-found');
+ await tt.withNonexistentDoc().run(update1, set1).expectError('not-found');
+
+ await tt.withNonexistentDoc().run(set1, delete1).expectNoDoc();
+ await tt
+ .withNonexistentDoc()
+ .run(set1, update2)
+ .expectDoc({ foo: 'bar2' });
+ await tt.withNonexistentDoc().run(set1, set2).expectDoc({ foo: 'bar2' });
+ });
+ });
+
+ it('set document with merge', () => {
+ return integrationHelpers.withTestDb(persistence, db => {
+ const doc = db.collection('towns').doc();
+ return db
+ .runTransaction(async transaction => {
+ transaction.set(doc, { a: 'b', nested: { a: 'b' } }).set(
+ doc,
+ { c: 'd', nested: { c: 'd' } },
+ {
+ merge: true
+ }
+ );
+ })
+ .then(() => {
+ return doc.get();
+ })
+ .then(snapshot => {
+ expect(snapshot.exists).to.equal(true);
+ expect(snapshot.data()).to.deep.equal({
+ a: 'b',
+ c: 'd',
+ nested: { a: 'b', c: 'd' }
+ });
+ });
+ });
+ });
+
+ it('can update nested fields transactionally', () => {
+ const initialData = {
+ desc: 'Description',
+ owner: { name: 'Jonny' },
+ 'is.admin': false
+ };
+ const finalData = {
+ desc: 'Description',
+ owner: { name: 'Sebastian' },
+ 'is.admin': true
+ };
+
+ return integrationHelpers.withTestDb(persistence, db => {
+ const doc = db.collection('counters').doc();
+ return db
+ .runTransaction(async transaction => {
+ transaction.set(doc, initialData);
+ transaction.update(
+ doc,
+ 'owner.name',
+ 'Sebastian',
+ new FieldPath('is.admin'),
+ true
+ );
+ })
+ .then(() => doc.get())
+ .then(docSnapshot => {
+ expect(docSnapshot.exists).to.be.ok;
+ expect(docSnapshot.data()).to.deep.equal(finalData);
+ });
+ });
+ });
+
+ it('retry when a document that was read without being written changes', () => {
+ return integrationHelpers.withTestDb(persistence, db => {
+ const doc1 = db.collection('counters').doc();
+ const doc2 = db.collection('counters').doc();
+ let tries = 0;
+ return doc1
+ .set({
+ count: 15
+ })
+ .then(() => {
+ return db.runTransaction(transaction => {
+ ++tries;
+
+ // Get the first doc.
+ return (
+ transaction
+ .get(doc1)
+ // 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.
+ .then(() => doc1.set({ count: 1234 }))
+ // Now try to update the other doc from within the
+ // transaction.
+ // This should fail once, because we read 15 earlier.
+ .then(() => transaction.set(doc2, { count: 16 }))
+ );
+ });
+ })
+ .then(async () => {
+ const snapshot = await doc1.get();
+ expect(tries).to.equal(2);
+ expect(snapshot.data()!['count']).to.equal(1234);
+ });
+ });
+ });
+
+ it('cannot read after writing', () => {
+ return integrationHelpers.withTestDb(persistence, db => {
+ return db
+ .runTransaction(transaction => {
+ const doc = db.collection('anything').doc();
+ transaction.set(doc, { foo: 'bar' });
+ return transaction.get(doc);
+ })
+ .then(() => {
+ expect.fail('transaction should fail');
+ })
+ .catch((err: firestore.FirestoreError) => {
+ expect(err).to.exist;
+ expect(err.code).to.equal('invalid-argument');
+ expect(err.message).to.contain(
+ 'Firestore transactions require all reads to be executed'
+ );
+ });
+ });
+ });
+
+ it(
+ 'cannot read non-existent document then update, even if ' +
+ 'document is written after the read',
+ () => {
+ return integrationHelpers.withTestDb(persistence, db => {
+ return db
+ .runTransaction(transaction => {
+ const doc = db.collection('nonexistent').doc();
+ return (
+ transaction
+ // Get and update a document that doesn't exist so that the transaction fails.
+ .get(doc)
+ // Do a write outside of the transaction.
+ .then(() => doc.set({ count: 1234 }))
+ // Now try to update the doc from within the transaction. This
+ // should fail, because the document didn't exist at the start
+ // of the transaction.
+ .then(() => transaction.update(doc, { count: 16 }))
+ );
+ })
+ .then(() => expect.fail('transaction should fail'))
+ .catch((err: firestore.FirestoreError) => {
+ expect(err).to.exist;
+ expect(err.code).to.equal('invalid-argument');
+ expect(err.message).to.contain(
+ "Can't update a document that doesn't exist."
+ );
+ });
+ });
+ }
+ );
+
+ describe('must return a promise:', () => {
+ const noop = (): void => {
+ /* -_- */
+ };
+ const badReturns = [
+ undefined,
+ null,
+ 5,
+ {},
+ { then: noop, noCatch: noop },
+ { noThen: noop, catch: noop }
+ ];
+
+ for (const badReturn of badReturns) {
+ it(badReturn + ' is rejected', () => {
+ // Intentionally returning bad type.
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const fn = ((txn: firestore.Transaction) => badReturn) as any;
+ return integrationHelpers.withTestDb(persistence, db => {
+ return db
+ .runTransaction(fn)
+ .then(() => expect.fail('transaction should fail'))
+ .catch(err => {
+ expect(err).to.exist;
+ expect(err.message).to.contain(
+ 'Transaction callback must return a Promise'
+ );
+ });
+ });
+ });
+ }
+ });
+
+ it('can have gets without mutations', () => {
+ return integrationHelpers.withTestDb(persistence, db => {
+ const docRef = db.collection('foo').doc();
+ const docRef2 = db.collection('foo').doc();
+ return docRef
+ .set({ foo: 'bar' })
+ .then(() => {
+ return db.runTransaction(async txn => {
+ await txn.get(docRef2);
+ return txn.get(docRef);
+ });
+ })
+ .then(snapshot => {
+ expect(snapshot).to.exist;
+ expect(snapshot.data()!['foo']).to.equal('bar');
+ });
+ });
+ });
+
+ it('does not retry on permanent errors', () => {
+ return integrationHelpers.withTestDb(persistence, db => {
+ let counter = 0;
+ return db
+ .runTransaction(transaction => {
+ // Make a transaction that should fail with a permanent error.
+ counter++;
+ const doc = db.collection('nonexistent').doc();
+ return (
+ transaction
+ // Get and update a document that doesn't exist so that the transaction fails.
+ .get(doc)
+ .then(() => transaction.update(doc, { count: 16 }))
+ );
+ })
+ .then(() => expect.fail('transaction should fail'))
+ .catch((err: firestore.FirestoreError) => {
+ expect(err.code).to.equal('invalid-argument');
+ expect(counter).to.equal(1);
+ });
+ });
+ });
+
+ it('are successful with no transaction operations', () => {
+ return integrationHelpers.withTestDb(persistence, db => {
+ return db.runTransaction(async txn => {});
+ });
+ });
+
+ it('are cancelled on rejected promise', () => {
+ return integrationHelpers.withTestDb(persistence, db => {
+ const doc = db.collection('towns').doc();
+ let counter = 0;
+ return db
+ .runTransaction(transaction => {
+ counter++;
+ transaction.set(doc, { foo: 'bar' });
+ return Promise.reject('no');
+ })
+ .then(() => expect.fail('transaction should fail'))
+ .catch(err => {
+ expect(err).to.exist;
+ expect(err).to.equal('no');
+ expect(counter).to.equal(1);
+ return doc.get();
+ })
+ .then(snapshot => {
+ expect((snapshot as firestore.DocumentSnapshot).exists).to.equal(
+ false
+ );
+ });
+ });
+ });
+
+ it('are cancelled on throw', () => {
+ return integrationHelpers.withTestDb(persistence, db => {
+ const doc = db.collection('towns').doc();
+ const failure = new Error('no');
+ let count = 0;
+ return db
+ .runTransaction(transaction => {
+ count++;
+ transaction.set(doc, { foo: 'bar' });
+ throw failure;
+ })
+ .then(() => expect.fail('transaction should fail'))
+ .catch(err => {
+ expect(err).to.exist;
+ expect(err).to.equal(failure);
+ expect(count).to.equal(1);
+ return doc.get();
+ })
+ .then(snapshot => {
+ expect((snapshot as firestore.DocumentSnapshot).exists).to.equal(
+ false
+ );
+ });
+ });
+ });
+
+ // PORTING NOTE: These tests are for FirestoreDataConverter support and apply
+ // only to web.
+ apiDescribe('withConverter() support', (persistence: boolean) => {
+ class Post {
+ constructor(readonly title: string, readonly author: string) {}
+ byline(): string {
+ return this.title + ', by ' + this.author;
+ }
+ }
+
+ it('for Transaction.set() and Transaction.get()', () => {
+ return integrationHelpers.withTestDb(persistence, db => {
+ const docRef = db
+ .collection('posts')
+ .doc()
+ .withConverter({
+ toFirestore(post: Post): firestore.DocumentData {
+ return { title: post.title, author: post.author };
+ },
+ fromFirestore(
+ snapshot: firestore.QueryDocumentSnapshot,
+ options: firestore.SnapshotOptions
+ ): Post {
+ const data = snapshot.data(options);
+ return new Post(data.title, data.author);
+ }
+ });
+ return docRef.set(new Post('post', 'author')).then(() => {
+ return db
+ .runTransaction(async transaction => {
+ const snapshot = await transaction.get(docRef);
+ expect(snapshot.data()!.byline()).to.equal('post, by author');
+ transaction.set(docRef, new Post('new post', 'author'));
+ })
+ .then(async () => {
+ const snapshot = await docRef.get();
+ expect(snapshot.data()!.byline()).to.equal('new post, by author');
+ });
+ });
+ });
+ });
+ });
+});
diff --git a/packages/firestore-compat/test/type.test.ts b/packages/firestore-compat/test/type.test.ts
new file mode 100644
index 00000000000..4de5c1647d4
--- /dev/null
+++ b/packages/firestore-compat/test/type.test.ts
@@ -0,0 +1,171 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as firestore from '@firebase/firestore-types';
+import { expect } from 'chai';
+
+import { addEqualityMatcher } from './util/equality_matcher';
+import { EventsAccumulator } from './util/events_accumulator';
+import * as firebaseExport from './util/firebase_export';
+import { apiDescribe, withTestDb, withTestDoc } from './util/helpers';
+
+const Blob = firebaseExport.Blob;
+const GeoPoint = firebaseExport.GeoPoint;
+const Timestamp = firebaseExport.Timestamp;
+
+apiDescribe('Firestore', (persistence: boolean) => {
+ addEqualityMatcher();
+
+ async function expectRoundtrip(
+ db: firestore.FirebaseFirestore,
+ data: {},
+ validateSnapshots = true,
+ expectedData?: {}
+ ): Promise {
+ expectedData = expectedData ?? data;
+
+ const collection = db.collection(db.collection('a').doc().id);
+ const doc = collection.doc();
+
+ await doc.set(data);
+ let docSnapshot = await doc.get();
+ expect(docSnapshot.data()).to.deep.equal(expectedData);
+
+ await doc.update(data);
+ docSnapshot = await doc.get();
+ expect(docSnapshot.data()).to.deep.equal(expectedData);
+
+ // Validate that the transaction API returns the same types
+ await db.runTransaction(async transaction => {
+ docSnapshot = await transaction.get(doc);
+ expect(docSnapshot.data()).to.deep.equal(expectedData);
+ });
+
+ if (validateSnapshots) {
+ let querySnapshot = await collection.get();
+ docSnapshot = querySnapshot.docs[0];
+ expect(docSnapshot.data()).to.deep.equal(expectedData);
+
+ const eventsAccumulator =
+ new EventsAccumulator();
+ const unlisten = collection.onSnapshot(eventsAccumulator.storeEvent);
+ querySnapshot = await eventsAccumulator.awaitEvent();
+ docSnapshot = querySnapshot.docs[0];
+ expect(docSnapshot.data()).to.deep.equal(expectedData);
+
+ unlisten();
+ }
+
+ return docSnapshot;
+ }
+
+ it('can read and write null fields', () => {
+ return withTestDb(persistence, async db => {
+ await expectRoundtrip(db, { a: 1, b: null });
+ });
+ });
+
+ it('can read and write number fields', () => {
+ return withTestDb(persistence, async db => {
+ // TODO(b/174486484): This test should always test -0.0, but right now
+ // this leaks to flakes as we turn -0.0 into 0.0 when we build the
+ // snapshot from IndexedDb
+ const validateSnapshots = !persistence;
+ await expectRoundtrip(
+ db,
+ { a: 1, b: NaN, c: Infinity, d: persistence ? 0.0 : -0.0 },
+ validateSnapshots
+ );
+ });
+ });
+
+ it('can read and write array fields', () => {
+ return withTestDb(persistence, async db => {
+ await expectRoundtrip(db, { array: [1, 'foo', { deep: true }, null] });
+ });
+ });
+
+ it('can read and write geo point fields', () => {
+ return withTestDb(persistence, async db => {
+ const docSnapshot = await expectRoundtrip(db, {
+ geopoint1: new GeoPoint(1.23, 4.56),
+ geopoint2: new GeoPoint(0, 0)
+ });
+
+ const latLong = docSnapshot.data()!['geopoint1'];
+ expect(latLong instanceof GeoPoint).to.equal(true);
+ expect(latLong.latitude).to.equal(1.23);
+ expect(latLong.longitude).to.equal(4.56);
+
+ const zeroLatLong = docSnapshot.data()!['geopoint2'];
+ expect(zeroLatLong instanceof GeoPoint).to.equal(true);
+ expect(zeroLatLong.latitude).to.equal(0);
+ expect(zeroLatLong.longitude).to.equal(0);
+ });
+ });
+
+ it('can read and write bytes fields', () => {
+ return withTestDb(persistence, async db => {
+ const docSnapshot = await expectRoundtrip(db, {
+ bytes: Blob.fromUint8Array(new Uint8Array([0, 1, 255]))
+ });
+
+ const blob = docSnapshot.data()!['bytes'];
+ // TODO(firestorexp): As part of the Compat migration, the SDK
+ // should re-wrap the firestore-exp types into the Compat API.
+ // Comment this change back in once this is complete (note that this
+ // check passes in the legacy API).
+ // expect(blob instanceof Blob).to.equal(true);
+ expect(blob.toUint8Array()).to.deep.equal(new Uint8Array([0, 1, 255]));
+ });
+ });
+
+ it('can read and write date fields', () => {
+ return withTestDb(persistence, async db => {
+ const date = new Date('2017-04-10T09:10:11.123Z');
+ // Dates are returned as Timestamps
+ const data = { date };
+ const expectedData = { date: Timestamp.fromDate(date) };
+
+ await expectRoundtrip(
+ db,
+ data,
+ /* validateSnapshot= */ true,
+ expectedData
+ );
+ });
+ });
+
+ it('can read and write timestamp fields', () => {
+ return withTestDb(persistence, async db => {
+ const timestampValue = Timestamp.now();
+ await expectRoundtrip(db, { timestamp: timestampValue });
+ });
+ });
+
+ it('can read and write document references', () => {
+ return withTestDoc(persistence, async doc => {
+ await expectRoundtrip(doc.firestore, { a: 42, ref: doc });
+ });
+ });
+
+ it('can read and write document references in an array', () => {
+ return withTestDoc(persistence, async doc => {
+ await expectRoundtrip(doc.firestore, { a: 42, refs: [doc] });
+ });
+ });
+});
diff --git a/packages/firestore-compat/test/util/equality_matcher.ts b/packages/firestore-compat/test/util/equality_matcher.ts
new file mode 100644
index 00000000000..8047894c2dd
--- /dev/null
+++ b/packages/firestore-compat/test/util/equality_matcher.ts
@@ -0,0 +1,177 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import { use } from 'chai';
+
+/**
+ * Duck-typed interface for objects that have an isEqual() method.
+ *
+ * Note: This is copied from src/util/misc.ts to avoid importing private types.
+ */
+export interface Equatable {
+ isEqual(other: T): boolean;
+}
+
+/**
+ * Custom equals override for types that have a free-standing equals functions
+ * (such as `queryEquals()`).
+ */
+export interface CustomMatcher {
+ equalsFn: (left: T, right: T) => boolean;
+ // eslint-disable-next-line @typescript-eslint/ban-types
+ forType: Function;
+}
+
+/**
+ * @file This file provides a helper function to add a matcher that matches
+ * based on an objects isEqual method. If the isEqual method is present one
+ * either object it is used to determine equality, else mocha's default isEqual
+ * implementation is used.
+ */
+
+function customDeepEqual(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ customMatchers: Array>,
+ left: unknown,
+ right: unknown
+): boolean {
+ for (const customMatcher of customMatchers) {
+ if (
+ left instanceof customMatcher.forType &&
+ right instanceof customMatcher.forType
+ ) {
+ return customMatcher.equalsFn(left, right);
+ }
+ }
+ if (left && typeof left === 'object' && right && typeof right === 'object') {
+ // The `isEqual` check below returns true if firestore-exp types are
+ // compared with API types from Firestore classic. We do want to
+ // differentiate between these types in our tests to ensure that the we do
+ // not return firestore-exp types in the classic SDK.
+ const leftObj = left as Record;
+ const rightObj = right as Record;
+ if (
+ leftObj.constructor.name === rightObj.constructor.name &&
+ leftObj.constructor !== rightObj.constructor
+ ) {
+ return false;
+ }
+ }
+ if (typeof left === 'object' && left && 'isEqual' in left) {
+ return (left as Equatable).isEqual(right);
+ }
+ if (typeof right === 'object' && right && 'isEqual' in right) {
+ return (right as Equatable).isEqual(left);
+ }
+ if (left === right) {
+ if (left === 0.0 && right === 0.0) {
+ // Firestore treats -0.0 and +0.0 as not equals, even though JavaScript
+ // treats them as equal by default. Implemented based on MDN's Object.is()
+ // polyfill.
+ return 1 / left === 1 / right;
+ } else {
+ return true;
+ }
+ }
+ if (
+ typeof left === 'number' &&
+ typeof right === 'number' &&
+ isNaN(left) &&
+ isNaN(right)
+ ) {
+ return true;
+ }
+ if (typeof left !== typeof right) {
+ return false;
+ } // needed for structurally different objects
+ if (Object(left) !== left) {
+ return false;
+ } // primitive values
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const keys = Object.keys(left as any);
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ if (keys.length !== Object.keys(right as any).length) {
+ return false;
+ }
+ for (let i = 0; i < keys.length; i++) {
+ const key = keys[i];
+ if (!Object.prototype.hasOwnProperty.call(right, key)) {
+ return false;
+ }
+ if (
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ !customDeepEqual(customMatchers, (left as any)[key], (right as any)[key])
+ ) {
+ return false;
+ }
+ }
+ return true;
+}
+
+/** The original equality function passed in by chai(). */
+let originalFunction: ((r: unknown, l: unknown) => boolean) | null = null;
+
+export function addEqualityMatcher(
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ ...customMatchers: Array>
+): void {
+ let isActive = true;
+
+ before(() => {
+ use((chai, utils) => {
+ const Assertion = chai.Assertion;
+
+ // eslint-disable-next-line @typescript-eslint/explicit-function-return-type
+ const assertEql = (_super: (r: unknown, l: unknown) => boolean) => {
+ originalFunction = originalFunction || _super;
+ return function (
+ this: Chai.Assertion,
+ expected?: unknown,
+ msg?: unknown
+ ): void {
+ if (isActive) {
+ utils.flag(this, 'message', msg);
+ const actual = utils.flag(this, 'object');
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const assertion = new (chai.Assertion as any)();
+ utils.transferFlags(this, assertion, /*includeAll=*/ true);
+ // NOTE: Unlike the top-level chai assert() method, Assertion.assert()
+ // takes the expected value before the actual value.
+ assertion.assert(
+ customDeepEqual(customMatchers, actual, expected),
+ 'expected #{act} to roughly deeply equal #{exp}',
+ 'expected #{act} to not roughly deeply equal #{exp}',
+ expected,
+ actual,
+ /*showDiff=*/ true
+ );
+ } else if (originalFunction) {
+ originalFunction.call(this, expected, msg);
+ }
+ };
+ };
+
+ Assertion.overwriteMethod('eql', assertEql);
+ Assertion.overwriteMethod('eqls', assertEql);
+ });
+ });
+
+ after(() => {
+ isActive = false;
+ });
+}
diff --git a/packages/firestore-compat/test/util/events_accumulator.ts b/packages/firestore-compat/test/util/events_accumulator.ts
new file mode 100644
index 00000000000..cd316b96a51
--- /dev/null
+++ b/packages/firestore-compat/test/util/events_accumulator.ts
@@ -0,0 +1,117 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as firestore from '@firebase/firestore-types';
+import { expect } from 'chai';
+
+import { Deferred } from './promise';
+
+/**
+ * A helper object that can accumulate an arbitrary amount of events and resolve
+ * a promise when expected number has been emitted.
+ */
+export class EventsAccumulator<
+ T extends firestore.DocumentSnapshot | firestore.QuerySnapshot
+> {
+ private events: T[] = [];
+ private waitingFor: number = 0;
+ private deferred: Deferred | null = null;
+ private rejectAdditionalEvents = false;
+
+ storeEvent: (evt: T) => void = (evt: T) => {
+ if (this.rejectAdditionalEvents) {
+ throw new Error(
+ 'Additional event detected after assertNoAdditionalEvents called' +
+ JSON.stringify(evt)
+ );
+ }
+ this.events.push(evt);
+ this.checkFulfilled();
+ };
+
+ awaitEvents(length: number): Promise {
+ expect(this.deferred).to.equal(null, 'Already waiting for events.');
+ this.waitingFor = length;
+ this.deferred = new Deferred();
+ const promise = this.deferred.promise;
+ this.checkFulfilled();
+ return promise;
+ }
+
+ awaitEvent(): Promise {
+ return this.awaitEvents(1).then(events => events[0]);
+ }
+
+ /** Waits for a latency compensated local snapshot. */
+ async awaitLocalEvent(): Promise {
+ const snapshot = await this.awaitEvent();
+ if (snapshot.metadata.hasPendingWrites) {
+ return snapshot;
+ } else {
+ return this.awaitLocalEvent();
+ }
+ }
+
+ /** Waits for multiple latency compensated local snapshot. */
+ async awaitLocalEvents(count: number): Promise {
+ const results = [] as T[];
+ for (let i = 0; i < count; i++) {
+ results.push(await this.awaitLocalEvent());
+ }
+ return results;
+ }
+
+ /** Waits for a snapshot that has no pending writes */
+ async awaitRemoteEvent(): Promise {
+ const snapshot = await this.awaitEvent();
+ if (!snapshot.metadata.hasPendingWrites) {
+ return snapshot;
+ } else {
+ return this.awaitRemoteEvent();
+ }
+ }
+
+ assertNoAdditionalEvents(): Promise {
+ this.rejectAdditionalEvents = true;
+ return new Promise((resolve: (val: void) => void, reject) => {
+ setTimeout(() => {
+ if (this.events.length > 0) {
+ reject(
+ 'Received ' +
+ this.events.length +
+ ' events: ' +
+ JSON.stringify(this.events)
+ );
+ } else {
+ resolve(undefined);
+ }
+ }, 0);
+ });
+ }
+
+ allowAdditionalEvents(): void {
+ this.rejectAdditionalEvents = false;
+ }
+
+ private checkFulfilled(): void {
+ if (this.deferred !== null && this.events.length >= this.waitingFor) {
+ const events = this.events.splice(0, this.waitingFor);
+ this.deferred.resolve(events);
+ this.deferred = null;
+ }
+ }
+}
diff --git a/packages/firestore-compat/test/util/firebase_export.ts b/packages/firestore-compat/test/util/firebase_export.ts
new file mode 100644
index 00000000000..acf33bda732
--- /dev/null
+++ b/packages/firestore-compat/test/util/firebase_export.ts
@@ -0,0 +1,82 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+// Imports firebase via the raw sources and re-exports it. The
+// "/integration/firestore" test suite replaces this file with a
+// reference to the minified sources. If you change any exports in this file,
+// you need to also adjust "integration/firestore/firebase_export.ts".
+
+import firebase from '@firebase/app-compat';
+import { FirebaseApp } from '@firebase/app-types';
+import { GeoPoint, Timestamp } from '@firebase/firestore';
+import * as firestore from '@firebase/firestore-types';
+
+import { Blob } from '../../src/api/blob';
+import {
+ Firestore,
+ DocumentReference,
+ QueryDocumentSnapshot
+} from '../../src/api/database';
+import { FieldPath } from '../../src/api/field_path';
+import { FieldValue } from '../../src/api/field_value';
+
+// TODO(dimond): Right now we create a new app and Firestore instance for
+// every test and never clean them up. We may need to revisit.
+let appCount = 0;
+
+/**
+ * Creates a new test instance of Firestore using either firebase.firestore()
+ * or `initializeFirestore` from the modular API.
+ */
+export function newTestFirestore(
+ projectId: string,
+ nameOrApp?: string | FirebaseApp,
+ settings?: firestore.Settings
+): firestore.FirebaseFirestore {
+ if (nameOrApp === undefined) {
+ nameOrApp = 'test-app-' + appCount++;
+ }
+
+ const app =
+ typeof nameOrApp === 'string'
+ ? firebase.initializeApp(
+ {
+ apiKey: 'fake-api-key',
+ projectId
+ },
+ nameOrApp
+ )
+ : nameOrApp;
+
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const firestore = (firebase as any).firestore(app);
+ if (settings) {
+ firestore.settings(settings);
+ }
+ return firestore;
+}
+
+export {
+ Firestore,
+ FieldValue,
+ FieldPath,
+ Timestamp,
+ Blob,
+ GeoPoint,
+ DocumentReference,
+ QueryDocumentSnapshot
+};
diff --git a/packages/firestore-compat/test/util/helpers.ts b/packages/firestore-compat/test/util/helpers.ts
new file mode 100644
index 00000000000..e7d077fdb80
--- /dev/null
+++ b/packages/firestore-compat/test/util/helpers.ts
@@ -0,0 +1,276 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as firestore from '@firebase/firestore-types';
+import { isIndexedDBAvailable } from '@firebase/util';
+
+import * as firebaseExport from './firebase_export';
+import {
+ ALT_PROJECT_ID,
+ DEFAULT_PROJECT_ID,
+ DEFAULT_SETTINGS
+} from './settings';
+
+const newTestFirestore = firebaseExport.newTestFirestore;
+
+/* eslint-disable no-restricted-globals */
+
+function isIeOrEdge(): boolean {
+ if (!window.navigator) {
+ return false;
+ }
+
+ const ua = window.navigator.userAgent;
+ return (
+ ua.indexOf('MSIE ') > 0 ||
+ ua.indexOf('Trident/') > 0 ||
+ ua.indexOf('Edge/') > 0
+ );
+}
+
+export function isPersistenceAvailable(): boolean {
+ return (
+ typeof window === 'object' &&
+ isIndexedDBAvailable() &&
+ !isIeOrEdge() &&
+ (typeof process === 'undefined' ||
+ process.env?.INCLUDE_FIRESTORE_PERSISTENCE !== 'false')
+ );
+}
+
+/**
+ * A wrapper around Mocha's describe method that allows for it to be run with
+ * persistence both disabled and enabled (if the browser is supported).
+ */
+function apiDescribeInternal(
+ describeFn: Mocha.PendingSuiteFunction,
+ message: string,
+ testSuite: (persistence: boolean) => void
+): void {
+ const persistenceModes = [false];
+ if (isPersistenceAvailable()) {
+ persistenceModes.push(true);
+ }
+
+ for (const enabled of persistenceModes) {
+ describeFn(`(Persistence=${enabled}) ${message}`, () => testSuite(enabled));
+ }
+}
+
+type ApiSuiteFunction = (
+ message: string,
+ testSuite: (persistence: boolean) => void
+) => void;
+interface ApiDescribe {
+ (message: string, testSuite: (persistence: boolean) => void): void;
+ skip: ApiSuiteFunction;
+ only: ApiSuiteFunction;
+}
+
+export const apiDescribe = apiDescribeInternal.bind(
+ null,
+ describe
+) as ApiDescribe;
+// eslint-disable-next-line no-restricted-properties
+apiDescribe.skip = apiDescribeInternal.bind(null, describe.skip);
+// eslint-disable-next-line no-restricted-properties
+apiDescribe.only = apiDescribeInternal.bind(null, describe.only);
+
+/** Converts the documents in a QuerySnapshot to an array with the data of each document. */
+export function toDataArray(
+ docSet: firestore.QuerySnapshot
+): firestore.DocumentData[] {
+ return docSet.docs.map(d => d.data());
+}
+
+/** Converts the changes in a QuerySnapshot to an array with the data of each document. */
+export function toChangesArray(
+ docSet: firestore.QuerySnapshot,
+ options?: firestore.SnapshotListenOptions
+): firestore.DocumentData[] {
+ return docSet.docChanges(options).map(d => d.doc.data());
+}
+
+export function toDataMap(docSet: firestore.QuerySnapshot): {
+ [field: string]: firestore.DocumentData;
+} {
+ const docsData: { [field: string]: firestore.DocumentData } = {};
+ docSet.forEach(doc => {
+ docsData[doc.id] = doc.data();
+ });
+ return docsData;
+}
+
+/** Converts a DocumentSet to an array with the id of each document */
+export function toIds(docSet: firestore.QuerySnapshot): string[] {
+ return docSet.docs.map(d => d.id);
+}
+
+export function withTestDb(
+ persistence: boolean,
+ fn: (db: firestore.FirebaseFirestore) => Promise
+): Promise {
+ return withTestDbs(persistence, 1, ([db]) => {
+ return fn(db);
+ });
+}
+
+/** Runs provided fn with a db for an alternate project id. */
+export function withAlternateTestDb(
+ persistence: boolean,
+ fn: (db: firestore.FirebaseFirestore) => Promise
+): Promise {
+ return withTestDbsSettings(
+ persistence,
+ ALT_PROJECT_ID,
+ DEFAULT_SETTINGS,
+ 1,
+ ([db]) => {
+ return fn(db);
+ }
+ );
+}
+
+export function withTestDbs(
+ persistence: boolean,
+ numDbs: number,
+ fn: (db: firestore.FirebaseFirestore[]) => Promise
+): Promise {
+ return withTestDbsSettings(
+ persistence,
+ DEFAULT_PROJECT_ID,
+ DEFAULT_SETTINGS,
+ numDbs,
+ fn
+ );
+}
+export async function withTestDbsSettings(
+ persistence: boolean,
+ projectId: string,
+ settings: firestore.Settings,
+ numDbs: number,
+ fn: (db: firestore.FirebaseFirestore[]) => Promise
+): Promise {
+ if (numDbs === 0) {
+ throw new Error("Can't test with no databases");
+ }
+
+ const dbs: firestore.FirebaseFirestore[] = [];
+
+ for (let i = 0; i < numDbs; i++) {
+ const db = newTestFirestore(projectId, /* name =*/ undefined, settings);
+ if (persistence) {
+ await db.enablePersistence();
+ }
+ dbs.push(db);
+ }
+
+ try {
+ await fn(dbs);
+ } finally {
+ for (const db of dbs) {
+ await db.terminate();
+ if (persistence) {
+ await db.clearPersistence();
+ }
+ }
+ }
+}
+
+export function withTestDoc(
+ persistence: boolean,
+ fn: (doc: firestore.DocumentReference) => Promise
+): Promise {
+ return withTestDb(persistence, db => {
+ return fn(db.collection('test-collection').doc());
+ });
+}
+
+export function withTestDocAndSettings(
+ persistence: boolean,
+ settings: firestore.Settings,
+ fn: (doc: firestore.DocumentReference) => Promise
+): Promise {
+ return withTestDbsSettings(
+ persistence,
+ DEFAULT_PROJECT_ID,
+ settings,
+ 1,
+ ([db]) => {
+ return fn(db.collection('test-collection').doc());
+ }
+ );
+}
+
+// TODO(rsgowman): Modify withTestDoc to take in (an optional) initialData and
+// fix existing usages of it. Then delete this function. This makes withTestDoc
+// more analogous to withTestCollection and eliminates the pattern of
+// `withTestDoc(..., docRef => { docRef.set(initialData) ...});` that otherwise is
+// quite common.
+export function withTestDocAndInitialData(
+ persistence: boolean,
+ initialData: firestore.DocumentData | null,
+ fn: (doc: firestore.DocumentReference) => Promise
+): Promise {
+ return withTestDb(persistence, db => {
+ const docRef: firestore.DocumentReference = db
+ .collection('test-collection')
+ .doc();
+ if (initialData) {
+ return docRef.set(initialData).then(() => fn(docRef));
+ } else {
+ return fn(docRef);
+ }
+ });
+}
+
+export function withTestCollection(
+ persistence: boolean,
+ docs: { [key: string]: firestore.DocumentData },
+ fn: (collection: firestore.CollectionReference) => Promise
+): Promise {
+ return withTestCollectionSettings(persistence, DEFAULT_SETTINGS, docs, fn);
+}
+
+// TODO(mikelehen): Once we wipe the database between tests, we can probably
+// return the same collection every time.
+export function withTestCollectionSettings(
+ persistence: boolean,
+ settings: firestore.Settings,
+ docs: { [key: string]: firestore.DocumentData },
+ fn: (collection: firestore.CollectionReference) => Promise
+): Promise {
+ return withTestDbsSettings(
+ persistence,
+ DEFAULT_PROJECT_ID,
+ settings,
+ 2,
+ ([testDb, setupDb]) => {
+ // Abuse .doc() to get a random ID.
+ const collectionId = 'test-collection-' + testDb.collection('x').doc().id;
+ const testCollection = testDb.collection(collectionId);
+ const setupCollection = setupDb.collection(collectionId);
+ const sets: Array> = [];
+ Object.keys(docs).forEach(key => {
+ sets.push(setupCollection.doc(key).set(docs[key]));
+ });
+ return Promise.all(sets).then(() => {
+ return fn(testCollection);
+ });
+ }
+ );
+}
diff --git a/packages/firestore-compat/test/util/promise.ts b/packages/firestore-compat/test/util/promise.ts
new file mode 100644
index 00000000000..45a0f1500e3
--- /dev/null
+++ b/packages/firestore-compat/test/util/promise.ts
@@ -0,0 +1,38 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+export interface Resolver {
+ (value: R | Promise): void;
+}
+
+export interface Rejecter {
+ (reason?: Error): void;
+}
+
+export class Deferred {
+ promise: Promise;
+ // Assigned synchronously in constructor by Promise constructor callback.
+ resolve!: Resolver;
+ reject!: Rejecter;
+
+ constructor() {
+ this.promise = new Promise((resolve: Resolver, reject: Rejecter) => {
+ this.resolve = resolve;
+ this.reject = reject;
+ });
+ }
+}
diff --git a/packages/firestore-compat/test/util/settings.ts b/packages/firestore-compat/test/util/settings.ts
new file mode 100644
index 00000000000..0906d5273ef
--- /dev/null
+++ b/packages/firestore-compat/test/util/settings.ts
@@ -0,0 +1,62 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as firestore from '@firebase/firestore-types';
+
+/**
+ * NOTE: These helpers are used by api/ tests and therefore may not have any
+ * dependencies on src/ files.
+ */
+// __karma__ is an untyped global
+// eslint-disable-next-line @typescript-eslint/no-explicit-any
+declare const __karma__: any;
+
+// eslint-disable-next-line @typescript-eslint/no-require-imports
+const PROJECT_CONFIG = require('../../../../config/project.json');
+
+const EMULATOR_PORT = process.env.FIRESTORE_EMULATOR_PORT;
+const EMULATOR_PROJECT_ID = process.env.FIRESTORE_EMULATOR_PROJECT_ID;
+export const USE_EMULATOR = !!EMULATOR_PORT;
+
+const EMULATOR_FIRESTORE_SETTING = {
+ host: `localhost:${EMULATOR_PORT}`,
+ ssl: false
+};
+
+const PROD_FIRESTORE_SETTING = {
+ host: 'firestore.googleapis.com',
+ ssl: true
+};
+
+export const DEFAULT_SETTINGS = getDefaultSettings();
+
+// eslint-disable-next-line no-console
+console.log(`Default Settings: ${JSON.stringify(DEFAULT_SETTINGS)}`);
+
+function getDefaultSettings(): firestore.Settings {
+ const karma = typeof __karma__ !== 'undefined' ? __karma__ : undefined;
+ if (karma && karma.config.firestoreSettings) {
+ return karma.config.firestoreSettings;
+ } else {
+ return USE_EMULATOR ? EMULATOR_FIRESTORE_SETTING : PROD_FIRESTORE_SETTING;
+ }
+}
+
+export const DEFAULT_PROJECT_ID = USE_EMULATOR
+ ? EMULATOR_PROJECT_ID
+ : PROJECT_CONFIG.projectId;
+export const ALT_PROJECT_ID = 'test-db2';
diff --git a/packages/firestore-compat/test/validation.test.ts b/packages/firestore-compat/test/validation.test.ts
new file mode 100644
index 00000000000..6e1db126421
--- /dev/null
+++ b/packages/firestore-compat/test/validation.test.ts
@@ -0,0 +1,1425 @@
+/**
+ * @license
+ * Copyright 2022 Google LLC
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+
+import * as firestore from '@firebase/firestore-types';
+import { expect } from 'chai';
+
+import * as firebaseExport from './util/firebase_export';
+import {
+ apiDescribe,
+ withAlternateTestDb,
+ withTestCollection,
+ withTestDb
+} from './util/helpers';
+import { Deferred } from './util/promise';
+import { ALT_PROJECT_ID, DEFAULT_PROJECT_ID } from './util/settings';
+
+const FieldPath = firebaseExport.FieldPath;
+const FieldValue = firebaseExport.FieldValue;
+const newTestFirestore = firebaseExport.newTestFirestore;
+
+// We're using 'as any' to pass invalid values to APIs for testing purposes.
+/* eslint-disable @typescript-eslint/no-explicit-any */
+
+interface ValidationIt {
+ (
+ persistence: boolean,
+ message: string,
+ testFunction: (db: firestore.FirebaseFirestore) => void | Promise
+ ): void;
+ skip: (
+ persistence: boolean,
+ message: string,
+ testFunction: (db: firestore.FirebaseFirestore) => void | Promise
+ ) => void;
+ only: (
+ persistence: boolean,
+ message: string,
+ testFunction: (db: firestore.FirebaseFirestore) => void | Promise
+ ) => void;
+}
+
+// Since most of our tests are "synchronous" but require a Firestore instance,
+// we have a helper wrapper around it() and withTestDb() to optimize for that.
+const validationIt: ValidationIt = Object.assign(
+ (
+ persistence: boolean,
+ message: string,
+ testFunction: (db: firestore.FirebaseFirestore) => void | Promise
+ ) => {
+ it(message, () => {
+ return withTestDb(persistence, async db => {
+ const maybePromise = testFunction(db);
+ if (maybePromise) {
+ return maybePromise;
+ }
+ });
+ });
+ },
+ {
+ skip(
+ persistence: boolean,
+ message: string,
+ _: (db: firestore.FirebaseFirestore) => void | Promise
+ ): void {
+ // eslint-disable-next-line no-restricted-properties
+ it.skip(message, () => {});
+ },
+ only(
+ persistence: boolean,
+ message: string,
+ testFunction: (db: firestore.FirebaseFirestore) => void | Promise
+ ): void {
+ // eslint-disable-next-line no-restricted-properties
+ it.only(message, () => {
+ return withTestDb(persistence, async db => {
+ const maybePromise = testFunction(db);
+ if (maybePromise) {
+ return maybePromise;
+ }
+ });
+ });
+ }
+ }
+);
+
+/** Class used to verify custom classes can't be used in writes. */
+class TestClass {
+ constructor(readonly property: string) {}
+}
+
+apiDescribe('Validation:', (persistence: boolean) => {
+ describe('FirestoreSettings', () => {
+ // Enabling persistence counts as a use of the firestore instance, meaning
+ // that it will be impossible to verify that a set of settings don't throw,
+ // and additionally that some exceptions happen for specific reasons, rather
+ // than persistence having already been enabled.
+ if (persistence) {
+ return;
+ }
+
+ validationIt(
+ persistence,
+ 'disallows changing settings after use',
+ async db => {
+ await db.doc('foo/bar').set({});
+ expect(() =>
+ db.settings({ host: 'something-else.example.com' })
+ ).to.throw(
+ 'Firestore has already been started and its settings can no ' +
+ 'longer be changed. You can only modify settings before calling any other ' +
+ 'methods on a Firestore object.'
+ );
+ }
+ );
+
+ validationIt(persistence, 'enforces minimum cache size', () => {
+ const db = newTestFirestore('test-project');
+ expect(() => db.settings({ cacheSizeBytes: 1 })).to.throw(
+ 'cacheSizeBytes must be at least 1048576'
+ );
+ });
+
+ validationIt(persistence, 'garbage collection can be disabled', () => {
+ const db = newTestFirestore('test-project');
+ // Verify that this doesn't throw.
+ db.settings({ cacheSizeBytes: /* CACHE_SIZE_UNLIMITED= */ -1 });
+ });
+
+ validationIt(persistence, 'useEmulator can set host and port', () => {
+ const db = newTestFirestore('test-project');
+ // Verify that this doesn't throw.
+ db.useEmulator('localhost', 9000);
+ });
+
+ validationIt(
+ persistence,
+ 'disallows calling useEmulator after use',
+ async db => {
+ const errorMsg =
+ 'Firestore has already been started and its settings can no longer be changed.';
+
+ await db.doc('foo/bar').set({});
+ expect(() => db.useEmulator('localhost', 9000)).to.throw(errorMsg);
+ }
+ );
+
+ validationIt(
+ persistence,
+ 'useEmulator can set mockUserToken object',
+ () => {
+ const db = newTestFirestore('test-project');
+ // Verify that this doesn't throw.
+ db.useEmulator('localhost', 9000, { mockUserToken: { sub: 'foo' } });
+ }
+ );
+
+ validationIt(
+ persistence,
+ 'useEmulator can set mockUserToken string',
+ () => {
+ const db = newTestFirestore('test-project');
+ // Verify that this doesn't throw.
+ db.useEmulator('localhost', 9000, {
+ mockUserToken: 'my-mock-user-token'
+ });
+ }
+ );
+
+ validationIt(
+ persistence,
+ 'throws if sub / user_id is missing in mockUserToken',
+ async db => {
+ const errorMsg = "mockUserToken must contain 'sub' or 'user_id' field!";
+
+ expect(() =>
+ db.useEmulator('localhost', 9000, { mockUserToken: {} as any })
+ ).to.throw(errorMsg);
+ }
+ );
+ });
+
+ describe('Firestore', () => {
+ (persistence ? validationIt : validationIt.skip)(
+ persistence,
+ 'disallows calling enablePersistence after use',
+ db => {
+ // Calling `enablePersistence()` itself counts as use, so we should only
+ // need this method when persistence is not enabled.
+ if (!persistence) {
+ db.doc('foo/bar');
+ }
+ expect(() => db.enablePersistence()).to.throw(
+ 'Firestore has already been started and persistence can no ' +
+ 'longer be enabled. You can only enable persistence before ' +
+ 'calling any other methods on a Firestore object.'
+ );
+ }
+ );
+
+ it("fails transaction if function doesn't return a Promise.", () => {
+ return withTestDb(persistence, db => {
+ return db
+ .runTransaction(() => 5 as any)
+ .then(
+ x => expect.fail('Transaction should fail'),
+ err => {
+ expect(err.message).to.equal(
+ 'Transaction callback must return a Promise'
+ );
+ }
+ );
+ });
+ });
+ });
+
+ describe('Collection paths', () => {
+ validationIt(persistence, 'must be non-empty strings', db => {
+ const baseDocRef = db.doc('foo/bar');
+ expect(() => db.collection('')).to.throw(
+ 'Function Firestore.collection() cannot be called with an empty path.'
+ );
+ expect(() => baseDocRef.collection('')).to.throw(
+ 'Function DocumentReference.collection() cannot be called with an ' +
+ 'empty path.'
+ );
+ });
+
+ validationIt(persistence, 'must be odd-length', db => {
+ const baseDocRef = db.doc('foo/bar');
+ const badAbsolutePaths = ['foo/bar', 'foo/bar/baz/quu'];
+ const badRelativePaths = ['/', 'baz/quu'];
+ const badPathLengths = [2, 4];
+
+ for (let i = 0; i < badAbsolutePaths.length; i++) {
+ const error =
+ 'Invalid collection reference. Collection references ' +
+ 'must have an odd number of segments, but ' +
+ `${badAbsolutePaths[i]} has ${badPathLengths[i]}`;
+ expect(() => db.collection(badAbsolutePaths[i])).to.throw(error);
+ expect(() => baseDocRef.collection(badRelativePaths[i])).to.throw(
+ error
+ );
+ }
+ });
+
+ validationIt(persistence, 'must not have empty segments', db => {
+ // NOTE: leading / trailing slashes are okay.
+ db.collection('/foo/');
+ db.collection('/foo');
+ db.collection('foo/');
+
+ const badPaths = ['foo//bar//baz', '//foo', 'foo//'];
+ const collection = db.collection('test-collection');
+ const doc = collection.doc('test-document');
+ for (const path of badPaths) {
+ const reason = `Invalid segment (${path}). Paths must not contain // in them.`;
+ expect(() => db.collection(path)).to.throw(reason);
+ expect(() => db.doc(path)).to.throw(reason);
+ expect(() => collection.doc(path)).to.throw(reason);
+ expect(() => doc.collection(path)).to.throw(reason);
+ }
+ });
+ });
+
+ describe('Document paths', () => {
+ validationIt(persistence, 'must be strings', db => {
+ const baseCollectionRef = db.collection('foo');
+
+ expect(() => db.doc('')).to.throw(
+ 'Function Firestore.doc() cannot be called with an empty path.'
+ );
+ expect(() => baseCollectionRef.doc('')).to.throw(
+ 'Function CollectionReference.doc() cannot be called with an empty ' +
+ 'path.'
+ );
+ });
+
+ validationIt(persistence, 'must be even-length', db => {
+ const baseCollectionRef = db.collection('foo');
+ const badAbsolutePaths = ['foo', 'foo/bar/baz'];
+ const badRelativePaths = ['/', 'bar/baz'];
+ const badPathLengths = [1, 3];
+
+ for (let i = 0; i < badAbsolutePaths.length; i++) {
+ const error =
+ 'Invalid document reference. Document references ' +
+ 'must have an even number of segments, but ' +
+ `${badAbsolutePaths[i]} has ${badPathLengths[i]}`;
+ expect(() => db.doc(badAbsolutePaths[i])).to.throw(error);
+ expect(() => baseCollectionRef.doc(badRelativePaths[i])).to.throw(
+ error
+ );
+ }
+ });
+ });
+
+ validationIt(persistence, 'Merge options are validated', db => {
+ const docRef = db.collection('test').doc();
+
+ expect(() => docRef.set({}, { merge: true, mergeFields: [] })).to.throw(
+ 'Invalid options passed to function DocumentReference.set(): You cannot specify both ' +
+ '"merge" and "mergeFields".'
+ );
+ expect(() => docRef.set({}, { merge: false, mergeFields: [] })).to.throw(
+ 'Invalid options passed to function DocumentReference.set(): You cannot specify both ' +
+ '"merge" and "mergeFields".'
+ );
+ });
+
+ describe('Writes', () => {
+ validationIt(persistence, 'must be objects.', db => {
+ // PORTING NOTE: The error for firebase.firestore.FieldValue.delete()
+ // is different for minified builds, so omit testing it specifically.
+ const badData = [
+ 42,
+ [1],
+ new Date(),
+ null,
+ () => {},
+ new TestClass('foo'),
+ undefined
+ ];
+ const errorDescriptions = [
+ '42',
+ 'an array',
+ 'a custom Date object',
+ 'null',
+ 'a function',
+ 'a custom TestClass object'
+ ];
+ const promises: Array> = [];
+ for (let i = 0; i < badData.length; i++) {
+ const error =
+ 'Data must be an object, but it was: ' + errorDescriptions[i];
+ promises.push(expectWriteToFail(db, badData[i], error));
+ }
+ return Promise.all(promises);
+ });
+
+ validationIt(
+ persistence,
+ 'must not contain custom objects or functions.',
+ db => {
+ const badData = [
+ { foo: new TestClass('foo') },
+ { foo: [new TestClass('foo')] },
+ { foo: { bar: new TestClass('foo') } },
+ { foo: () => {} },
+ { foo: [() => {}] },
+ { foo: { bar: () => {} } }
+ ];
+ const errorDescriptions = [
+ 'Unsupported field value: a custom TestClass object (found in field foo)',
+ 'Unsupported field value: a custom TestClass object',
+ 'Unsupported field value: a custom TestClass object (found in field foo.bar)',
+ 'Unsupported field value: a function (found in field foo)',
+ 'Unsupported field value: a function',
+ 'Unsupported field value: a function (found in field foo.bar)'
+ ];
+ const promises: Array> = [];
+ for (let i = 0; i < badData.length; i++) {
+ promises.push(
+ expectWriteToFail(db, badData[i], errorDescriptions[i])
+ );
+ }
+ return Promise.all(promises);
+ }
+ );
+
+ validationIt(
+ persistence,
+ 'must not contain field value transforms in arrays',
+ db => {
+ return expectWriteToFail(
+ db,
+ { 'array': [FieldValue.serverTimestamp()] },
+ 'FieldValue.serverTimestamp() is not currently supported inside arrays'
+ );
+ }
+ );
+
+ validationIt(
+ persistence,
+ 'must not contain directly nested arrays.',
+ db => {
+ return expectWriteToFail(
+ db,
+ { 'nested-array': [1, [2]] },
+ 'Nested arrays are not supported'
+ );
+ }
+ );
+
+ validationIt(persistence, 'may contain indirectly nested arrays.', db => {
+ const data = { 'nested-array': [1, { foo: [2] }] };
+
+ const ref = db.collection('foo').doc();
+ const ref2 = db.collection('foo').doc();
+
+ return ref
+ .set(data)
+ .then(() => {
+ return ref.firestore.batch().set(ref, data).commit();
+ })
+ .then(() => {
+ return ref.update(data);
+ })
+ .then(() => {
+ return ref.firestore.batch().update(ref, data).commit();
+ })
+ .then(() => {
+ return ref.firestore.runTransaction(async txn => {
+ // Note ref2 does not exist at this point so set that and update ref.
+ txn.update(ref, data);
+ txn.set(ref2, data);
+ });
+ });
+ });
+
+ validationIt(persistence, 'must not contain undefined.', db => {
+ // Note: This test uses the default setting for `ignoreUndefinedProperties`.
+ return expectWriteToFail(
+ db,
+ { foo: undefined },
+ 'Unsupported field value: undefined (found in field foo)'
+ );
+ });
+
+ validationIt(
+ persistence,
+ 'must not contain references to a different database',
+ db => {
+ return withAlternateTestDb(persistence, db2 => {
+ const ref = db2.doc('baz/quu');
+ const data = { foo: ref };
+ return expectWriteToFail(
+ db,
+ data,
+ `Document reference is for database ` +
+ `${ALT_PROJECT_ID}/(default) but should be for database ` +
+ `${DEFAULT_PROJECT_ID}/(default) (found in field ` +
+ `foo)`
+ );
+ });
+ }
+ );
+
+ validationIt(persistence, 'must not contain reserved field names.', db => {
+ return Promise.all([
+ expectWriteToFail(
+ db,
+ { __baz__: 1 },
+ 'Document fields cannot begin and end with "__" (found in field ' +
+ '__baz__)'
+ ),
+ expectWriteToFail(
+ db,
+ { foo: { __baz__: 1 } },
+ 'Document fields cannot begin and end with "__" (found in field ' +
+ 'foo.__baz__)'
+ ),
+ expectWriteToFail(
+ db,
+ { __baz__: { foo: 1 } },
+ 'Document fields cannot begin and end with "__" (found in field ' +
+ '__baz__)'
+ ),
+
+ expectUpdateToFail(
+ db,
+ { 'foo.__baz__': 1 },
+ 'Document fields cannot begin and end with "__" (found in field ' +
+ 'foo.__baz__)'
+ ),
+ expectUpdateToFail(
+ db,
+ { '__baz__.foo': 1 },
+ 'Document fields cannot begin and end with "__" (found in field ' +
+ '__baz__.foo)'
+ )
+ ]);
+ });
+
+ validationIt(persistence, 'must not contain empty field names.', db => {
+ return expectSetToFail(
+ db,
+ { '': 'foo' },
+ 'Document fields must not be empty (found in field ``)'
+ );
+ });
+
+ validationIt(
+ persistence,
+ 'via set() must not contain FieldValue.delete()',
+ db => {
+ return expectSetToFail(
+ db,
+ { foo: FieldValue.delete() },
+ 'FieldValue.delete() cannot be used with set() unless you pass ' +
+ '{merge:true} (found in field foo)'
+ );
+ }
+ );
+
+ validationIt(
+ persistence,
+ 'via update() must not contain nested FieldValue.delete()',
+ db => {
+ return expectUpdateToFail(
+ db,
+ { foo: { bar: FieldValue.delete() } },
+ 'FieldValue.delete() can only appear at the top level of your ' +
+ 'update data (found in field foo.bar)'
+ );
+ }
+ );
+ });
+
+ validationIt(
+ persistence,
+ 'Batch writes require correct Document References',
+ db => {
+ return withAlternateTestDb(persistence, async db2 => {
+ const badRef = db2.doc('foo/bar');
+ const reason =
+ 'Provided document reference is from a different Firestore instance.';
+ const data = { foo: 1 };
+ const batch = db.batch();
+ expect(() => batch.set(badRef, data)).to.throw(reason);
+ expect(() => batch.update(badRef, data)).to.throw(reason);
+ expect(() => batch.delete(badRef)).to.throw(reason);
+ });
+ }
+ );
+
+ validationIt(
+ persistence,
+ 'Transaction writes require correct Document References',
+ db => {
+ return withAlternateTestDb(persistence, db2 => {
+ const badRef = db2.doc('foo/bar');
+ const reason =
+ 'Provided document reference is from a different Firestore instance.';
+ const data = { foo: 1 };
+ return db.runTransaction(async txn => {
+ expect(() => txn.get(badRef)).to.throw(reason);
+ expect(() => txn.set(badRef, data)).to.throw(reason);
+ expect(() => txn.update(badRef, data)).to.throw(reason);
+ expect(() => txn.delete(badRef)).to.throw(reason);
+ });
+ });
+ }
+ );
+
+ validationIt(persistence, 'Field paths must not have empty segments', db => {
+ const docRef = db.collection('test').doc();
+ return docRef
+ .set({ test: 1 })
+ .then(() => {
+ return docRef.get();
+ })
+ .then(snapshot => {
+ const badFieldPaths = ['', 'foo..baz', '.foo', 'foo.'];
+ const promises: Array> = [];
+ for (const fieldPath of badFieldPaths) {
+ const reason =
+ `Invalid field path (${fieldPath}). Paths must not be ` +
+ `empty, begin with '.', end with '.', or contain '..'`;
+ promises.push(expectFieldPathToFail(snapshot, fieldPath, reason));
+ }
+ return Promise.all(promises);
+ });
+ });
+
+ validationIt(
+ persistence,
+ 'Field paths must not have invalid segments',
+ db => {
+ const docRef = db.collection('test').doc();
+ return docRef
+ .set({ test: 1 })
+ .then(() => {
+ return docRef.get();
+ })
+ .then(snapshot => {
+ const badFieldPaths = [
+ 'foo~bar',
+ 'foo*bar',
+ 'foo/bar',
+ 'foo[1',
+ 'foo]1',
+ 'foo[1]'
+ ];
+ const promises: Array> = [];
+ for (const fieldPath of badFieldPaths) {
+ const reason =
+ `Invalid field path (${fieldPath}). Paths must not ` +
+ `contain '~', '*', '/', '[', or ']'`;
+ promises.push(expectFieldPathToFail(snapshot, fieldPath, reason));
+ }
+ return Promise.all(promises);
+ });
+ }
+ );
+
+ describe('Array transforms', () => {
+ validationIt(persistence, 'fail in queries', db => {
+ const collection = db.collection('test');
+ expect(() =>
+ collection.where('test', '==', { test: FieldValue.arrayUnion(1) })
+ ).to.throw(
+ 'Function Query.where() called with invalid data. ' +
+ 'FieldValue.arrayUnion() can only be used with update() and set() ' +
+ '(found in field test)'
+ );
+
+ expect(() =>
+ collection.where('test', '==', { test: FieldValue.arrayRemove(1) })
+ ).to.throw(
+ 'Function Query.where() called with invalid data. ' +
+ 'FieldValue.arrayRemove() can only be used with update() and set() ' +
+ '(found in field test)'
+ );
+ });
+
+ validationIt(persistence, 'reject invalid elements', db => {
+ const doc = db.collection('test').doc();
+ expect(() =>
+ doc.set({ x: FieldValue.arrayUnion(1, new TestClass('foo')) })
+ ).to.throw(
+ 'Function FieldValue.arrayUnion() called with invalid data. ' +
+ 'Unsupported field value: a custom TestClass object'
+ );
+
+ expect(() =>
+ doc.set({ x: FieldValue.arrayRemove(1, new TestClass('foo')) })
+ ).to.throw(
+ 'Function FieldValue.arrayRemove() called with invalid data. ' +
+ 'Unsupported field value: a custom TestClass object'
+ );
+
+ expect(() => doc.set({ x: FieldValue.arrayRemove(undefined) })).to.throw(
+ 'Function FieldValue.arrayRemove() called with invalid data. ' +
+ 'Unsupported field value: undefined'
+ );
+ });
+
+ validationIt(persistence, 'reject arrays', db => {
+ const doc = db.collection('test').doc();
+ // This would result in a directly nested array which is not supported.
+ expect(() =>
+ doc.set({ x: FieldValue.arrayUnion(1, ['nested']) })
+ ).to.throw(
+ 'Function FieldValue.arrayUnion() called with invalid data. ' +
+ 'Nested arrays are not supported'
+ );
+
+ expect(() =>
+ doc.set({ x: FieldValue.arrayRemove(1, ['nested']) })
+ ).to.throw(
+ 'Function FieldValue.arrayRemove() called with invalid data. ' +
+ 'Nested arrays are not supported'
+ );
+ });
+ });
+
+ describe('Numeric transforms', () => {
+ validationIt(persistence, 'fail in queries', db => {
+ const collection = db.collection('test');
+ expect(() =>
+ collection.where('test', '==', { test: FieldValue.increment(1) })
+ ).to.throw(
+ 'Function Query.where() called with invalid data. ' +
+ 'FieldValue.increment() can only be used with update() and set() ' +
+ '(found in field test)'
+ );
+ });
+ });
+
+ describe('Queries', () => {
+ validationIt(persistence, 'with non-positive limit fail', db => {
+ const collection = db.collection('test');
+ expect(() => collection.limit(0)).to.throw(
+ `Function Query.limit() requires a positive number, but it was: 0.`
+ );
+ expect(() => collection.limitToLast(-1)).to.throw(
+ `Function Query.limitToLast() requires a positive number, but it was: -1.`
+ );
+ });
+
+ it('cannot be created from documents missing sort values', () => {
+ const testDocs = {
+ f: { k: 'f', nosort: 1 } // should not show up
+ };
+ return withTestCollection(persistence, testDocs, coll => {
+ const query = coll.orderBy('sort');
+ return coll
+ .doc('f')
+ .get()
+ .then(doc => {
+ expect(doc.data()).to.deep.equal({ k: 'f', nosort: 1 });
+ const reason =
+ `Invalid query. You are trying to start or end a ` +
+ `query using a document for which the field 'sort' (used as ` +
+ `the orderBy) does not exist.`;
+ expect(() => query.startAt(doc)).to.throw(reason);
+ expect(() => query.startAfter(doc)).to.throw(reason);
+ expect(() => query.endBefore(doc)).to.throw(reason);
+ expect(() => query.endAt(doc)).to.throw(reason);
+ });
+ });
+ });
+
+ validationIt(
+ persistence,
+ 'cannot be sorted by an uncommitted server timestamp',
+ db => {
+ return withTestCollection(
+ persistence,
+ /*docs=*/ {},
+ async (collection: firestore.CollectionReference) => {
+ await db.disableNetwork();
+
+ const offlineDeferred = new Deferred();
+ const onlineDeferred = new Deferred();
+
+ const unsubscribe = collection.onSnapshot(snapshot => {
+ // Skip the initial empty snapshot.
+ if (snapshot.empty) {
+ return;
+ }
+
+ expect(snapshot.docs).to.have.lengthOf(1);
+ const docSnap: firestore.DocumentSnapshot = snapshot.docs[0];
+
+ if (snapshot.metadata.hasPendingWrites) {
+ // Offline snapshot. Since the server timestamp is uncommitted,
+ // we shouldn't be able to query by it.
+ expect(() =>
+ collection
+ .orderBy('timestamp')
+ .endAt(docSnap)
+ .onSnapshot(() => {})
+ ).to.throw('uncommitted server timestamp');
+ offlineDeferred.resolve();
+ } else {
+ // Online snapshot. Since the server timestamp is committed, we
+ // should be able to query by it.
+ collection
+ .orderBy('timestamp')
+ .endAt(docSnap)
+ .onSnapshot(() => {});
+ onlineDeferred.resolve();
+ }
+ });
+
+ const doc: firestore.DocumentReference = collection.doc();
+ // eslint-disable-next-line @typescript-eslint/no-floating-promises
+ doc.set({ timestamp: FieldValue.serverTimestamp() });
+ await offlineDeferred.promise;
+
+ await db.enableNetwork();
+ await onlineDeferred.promise;
+
+ unsubscribe();
+ }
+ );
+ }
+ );
+
+ validationIt(
+ persistence,
+ 'must not have more components than order by.',
+ db => {
+ const collection = db.collection('collection');
+ const query = collection.orderBy('foo');
+ const reason =
+ 'Too many arguments provided to Query.startAt(). The number of ' +
+ 'arguments must be less than or equal to the number of orderBy() ' +
+ 'clauses';
+ expect(() => query.startAt(1, 2)).to.throw(reason);
+ expect(() => query.orderBy('bar').startAt(1, 2, 3)).to.throw(reason);
+ }
+ );
+
+ validationIt(
+ persistence,
+ 'order-by-key bounds must be strings without slashes.',
+ db => {
+ const query = db
+ .collection('collection')
+ .orderBy(FieldPath.documentId());
+ const cgQuery = db
+ .collectionGroup('collection')
+ .orderBy(FieldPath.documentId());
+ expect(() => query.startAt(1)).to.throw(
+ 'Invalid query. Expected a string for document ID in ' +
+ 'Query.startAt(), but got a number'
+ );
+ expect(() => query.startAt('foo/bar')).to.throw(
+ 'Invalid query. When querying a collection and ordering by ' +
+ 'FieldPath.documentId(), the value passed to Query.startAt() ' +
+ "must be a plain document ID, but 'foo/bar' contains a slash."
+ );
+ expect(() => cgQuery.startAt('foo')).to.throw(
+ 'Invalid query. When querying a collection group and ordering by ' +
+ 'FieldPath.documentId(), the value passed to Query.startAt() ' +
+ "must result in a valid document path, but 'foo' is not because " +
+ 'it contains an odd number of segments.'
+ );
+ }
+ );
+
+ validationIt(persistence, 'with different inequality fields fail', db => {
+ const collection = db.collection('test');
+ expect(() =>
+ collection.where('x', '>=', 32).where('y', '<', 'cat')
+ ).to.throw(
+ 'Invalid query. All where filters with an ' +
+ 'inequality (<, <=, !=, not-in, >, or >=) must be on the same field.' +
+ ` But you have inequality filters on 'x' and 'y'`
+ );
+ });
+
+ validationIt(persistence, 'with more than one != query fail', db => {
+ const collection = db.collection('test');
+ expect(() =>
+ collection.where('x', '!=', 32).where('x', '!=', 33)
+ ).to.throw("Invalid query. You cannot use more than one '!=' filter.");
+ });
+
+ validationIt(
+ persistence,
+ 'with != and inequality queries on different fields fail',
+ db => {
+ const collection = db.collection('test');
+ expect(() =>
+ collection.where('y', '>', 32).where('x', '!=', 33)
+ ).to.throw(
+ 'Invalid query. All where filters with an ' +
+ 'inequality (<, <=, !=, not-in, >, or >=) must be on the same field.' +
+ ` But you have inequality filters on 'y' and 'x`
+ );
+ }
+ );
+
+ validationIt(
+ persistence,
+ 'with != and inequality queries on different fields fail',
+ db => {
+ const collection = db.collection('test');
+ expect(() =>
+ collection.where('y', '>', 32).where('x', 'not-in', [33])
+ ).to.throw(
+ 'Invalid query. All where filters with an ' +
+ 'inequality (<, <=, !=, not-in, >, or >=) must be on the same field.' +
+ ` But you have inequality filters on 'y' and 'x`
+ );
+ }
+ );
+
+ validationIt(
+ persistence,
+ 'with inequality different than first orderBy fail.',
+ db => {
+ const collection = db.collection('test');
+ const reason =
+ `Invalid query. You have a where filter with an ` +
+ `inequality (<, <=, !=, not-in, >, or >=) on field 'x' and so you must also ` +
+ `use 'x' as your first argument to Query.orderBy(), but your first ` +
+ `orderBy() is on field 'y' instead.`;
+ expect(() => collection.where('x', '>', 32).orderBy('y')).to.throw(
+ reason
+ );
+ expect(() => collection.orderBy('y').where('x', '>', 32)).to.throw(
+ reason
+ );
+ expect(() =>
+ collection.where('x', '>', 32).orderBy('y').orderBy('x')
+ ).to.throw(reason);
+ expect(() =>
+ collection.orderBy('y').orderBy('x').where('x', '>', 32)
+ ).to.throw(reason);
+ expect(() => collection.where('x', '!=', 32).orderBy('y')).to.throw(
+ reason
+ );
+ }
+ );
+
+ validationIt(persistence, 'with multiple array filters fail', db => {
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'array-contains', 1)
+ .where('foo', 'array-contains', 2)
+ ).to.throw(
+ "Invalid query. You cannot use more than one 'array-contains' filter."
+ );
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'array-contains', 1)
+ .where('foo', 'array-contains-any', [2, 3])
+ ).to.throw(
+ "Invalid query. You cannot use 'array-contains-any' filters with " +
+ "'array-contains' filters."
+ );
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'array-contains-any', [2, 3])
+ .where('foo', 'array-contains', 1)
+ ).to.throw(
+ "Invalid query. You cannot use 'array-contains' filters with " +
+ "'array-contains-any' filters."
+ );
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'not-in', [2, 3])
+ .where('foo', 'array-contains', 1)
+ ).to.throw(
+ "Invalid query. You cannot use 'array-contains' filters with " +
+ "'not-in' filters."
+ );
+ });
+
+ validationIt(persistence, 'with != and not-in filters fail', db => {
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'not-in', [2, 3])
+ .where('foo', '!=', 4)
+ ).to.throw(
+ "Invalid query. You cannot use '!=' filters with 'not-in' filters."
+ );
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', '!=', 4)
+ .where('foo', 'not-in', [2, 3])
+ ).to.throw(
+ "Invalid query. You cannot use 'not-in' filters with '!=' filters."
+ );
+ });
+
+ validationIt(persistence, 'with multiple disjunctive filters fail', db => {
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'in', [1, 2])
+ .where('foo', 'in', [2, 3])
+ ).to.throw("Invalid query. You cannot use more than one 'in' filter.");
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'not-in', [1, 2])
+ .where('foo', 'not-in', [2, 3])
+ ).to.throw(
+ "Invalid query. You cannot use more than one 'not-in' filter."
+ );
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'array-contains-any', [1, 2])
+ .where('foo', 'array-contains-any', [2, 3])
+ ).to.throw(
+ "Invalid query. You cannot use more than one 'array-contains-any'" +
+ ' filter.'
+ );
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'array-contains-any', [2, 3])
+ .where('foo', 'in', [2, 3])
+ ).to.throw(
+ "Invalid query. You cannot use 'in' filters with " +
+ "'array-contains-any' filters."
+ );
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'in', [2, 3])
+ .where('foo', 'array-contains-any', [2, 3])
+ ).to.throw(
+ "Invalid query. You cannot use 'array-contains-any' filters with " +
+ "'in' filters."
+ );
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'not-in', [2, 3])
+ .where('foo', 'array-contains-any', [2, 3])
+ ).to.throw(
+ "Invalid query. You cannot use 'array-contains-any' filters with " +
+ "'not-in' filters."
+ );
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'array-contains-any', [2, 3])
+ .where('foo', 'not-in', [2, 3])
+ ).to.throw(
+ "Invalid query. You cannot use 'not-in' filters with " +
+ "'array-contains-any' filters."
+ );
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'not-in', [2, 3])
+ .where('foo', 'in', [2, 3])
+ ).to.throw(
+ "Invalid query. You cannot use 'in' filters with 'not-in' filters."
+ );
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'in', [2, 3])
+ .where('foo', 'not-in', [2, 3])
+ ).to.throw(
+ "Invalid query. You cannot use 'not-in' filters with 'in' filters."
+ );
+
+ // This is redundant with the above tests, but makes sure our validation
+ // doesn't get confused.
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'in', [2, 3])
+ .where('foo', 'array-contains', 1)
+ .where('foo', 'array-contains-any', [2])
+ ).to.throw(
+ "Invalid query. You cannot use 'array-contains-any' filters with 'in' filters."
+ );
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'array-contains', 1)
+ .where('foo', 'in', [2, 3])
+ .where('foo', 'array-contains-any', [2])
+ ).to.throw(
+ "Invalid query. You cannot use 'array-contains-any' filters with " +
+ "'array-contains' filters."
+ );
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'not-in', [2, 3])
+ .where('foo', 'array-contains', 2)
+ .where('foo', 'array-contains-any', [2])
+ ).to.throw(
+ "Invalid query. You cannot use 'array-contains' filters with " +
+ "'not-in' filters."
+ );
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'array-contains', 2)
+ .where('foo', 'in', [2])
+ .where('foo', 'not-in', [2, 3])
+ ).to.throw(
+ "Invalid query. You cannot use 'not-in' filters with " +
+ "'array-contains' filters."
+ );
+ });
+
+ validationIt(
+ persistence,
+ 'can have an IN filter with an array-contains filter.',
+ db => {
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'array-contains', 1)
+ .where('foo', 'in', [2, 3])
+ ).not.to.throw();
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'in', [2, 3])
+ .where('foo', 'array-contains', 1)
+ ).not.to.throw();
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'in', [2, 3])
+ .where('foo', 'array-contains', 1)
+ .where('foo', 'array-contains', 2)
+ ).to.throw(
+ "Invalid query. You cannot use more than one 'array-contains' filter."
+ );
+
+ expect(() =>
+ db
+ .collection('test')
+ .where('foo', 'array-contains', 1)
+ .where('foo', 'in', [2, 3])
+ .where('foo', 'in', [2, 3])
+ ).to.throw("Invalid query. You cannot use more than one 'in' filter.");
+ }
+ );
+
+ validationIt(
+ persistence,
+ 'enforce array requirements for disjunctive filters',
+ db => {
+ expect(() => db.collection('test').where('foo', 'in', 2)).to.throw(
+ "Invalid Query. A non-empty array is required for 'in' filters."
+ );
+
+ expect(() =>
+ db.collection('test').where('foo', 'array-contains-any', 2)
+ ).to.throw(
+ 'Invalid Query. A non-empty array is required for ' +
+ "'array-contains-any' filters."
+ );
+
+ expect(() =>
+ db
+ .collection('test')
+ // The 10 element max includes duplicates.
+ .where('foo', 'in', [1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9])
+ ).to.throw(
+ "Invalid Query. 'in' filters support a maximum of 10 elements in " +
+ 'the value array.'
+ );
+
+ expect(() =>
+ db
+ .collection('test')
+ .where(
+ 'foo',
+ 'array-contains-any',
+ [1, 2, 3, 4, 5, 6, 7, 8, 9, 9, 9]
+ )
+ ).to.throw(
+ "Invalid Query. 'array-contains-any' filters support a maximum of " +
+ '10 elements in the value array.'
+ );
+
+ expect(() => db.collection('test').where('foo', 'in', [])).to.throw(
+ "Invalid Query. A non-empty array is required for 'in' filters."
+ );
+
+ expect(() =>
+ db.collection('test').where('foo', 'array-contains-any', [])
+ ).to.throw(
+ 'Invalid Query. A non-empty array is required for ' +
+ "'array-contains-any' filters."
+ );
+ }
+ );
+
+ validationIt(
+ persistence,
+ 'must not specify starting or ending point after orderBy',
+ db => {
+ const collection = db.collection('collection');
+ const query = collection.orderBy('foo');
+ let reason =
+ 'Invalid query. You must not call startAt() or startAfter() before calling Query.orderBy().';
+ expect(() => query.startAt(1).orderBy('bar')).to.throw(reason);
+ expect(() => query.startAfter(1).orderBy('bar')).to.throw(reason);
+
+ reason =
+ 'Invalid query. You must not call endAt() or endBefore() before calling Query.orderBy().';
+ expect(() => query.endAt(1).orderBy('bar')).to.throw(reason);
+ expect(() => query.endBefore(1).orderBy('bar')).to.throw(reason);
+ }
+ );
+
+ validationIt(
+ persistence,
+ 'must be non-empty strings or references when filtering by document ID',
+ db => {
+ const collection = db.collection('test');
+ expect(() =>
+ collection.where(FieldPath.documentId(), '>=', '')
+ ).to.throw(
+ 'Invalid query. When querying with FieldPath.documentId(), you ' +
+ 'must provide a valid document ID, but it was an empty string.'
+ );
+ expect(() =>
+ collection.where(FieldPath.documentId(), '>=', 'foo/bar/baz')
+ ).to.throw(
+ `Invalid query. When querying a collection by ` +
+ `FieldPath.documentId(), you must provide a plain document ID, but ` +
+ `'foo/bar/baz' contains a '/' character.`
+ );
+ expect(() =>
+ collection.where(FieldPath.documentId(), '>=', 1)
+ ).to.throw(
+ 'Invalid query. When querying with FieldPath.documentId(), you must ' +
+ 'provide a valid string or a DocumentReference, but it was: 1.'
+ );
+ expect(() =>
+ db.collectionGroup('foo').where(FieldPath.documentId(), '>=', 'foo')
+ ).to.throw(
+ `Invalid query. When querying a collection group by ` +
+ `FieldPath.documentId(), the value provided must result in a valid document path, ` +
+ `but 'foo' is not because it has an odd number of segments (1).`
+ );
+
+ expect(() =>
+ collection.where(FieldPath.documentId(), 'array-contains', 1)
+ ).to.throw(
+ "Invalid Query. You can't perform 'array-contains' queries on " +
+ 'FieldPath.documentId().'
+ );
+
+ expect(() =>
+ collection.where(FieldPath.documentId(), 'array-contains-any', 1)
+ ).to.throw(
+ "Invalid Query. You can't perform 'array-contains-any' queries on " +
+ 'FieldPath.documentId().'
+ );
+ }
+ );
+
+ validationIt(
+ persistence,
+ 'using IN and document id must have proper document references in array',
+ db => {
+ const collection = db.collection('test');
+
+ expect(() =>
+ collection.where(FieldPath.documentId(), 'in', [collection.path])
+ ).not.to.throw();
+
+ expect(() =>
+ collection.where(FieldPath.documentId(), 'in', [''])
+ ).to.throw(
+ 'Invalid query. When querying with FieldPath.documentId(), you ' +
+ 'must provide a valid document ID, but it was an empty string.'
+ );
+
+ expect(() =>
+ collection.where(FieldPath.documentId(), 'in', ['foo/bar/baz'])
+ ).to.throw(
+ `Invalid query. When querying a collection by ` +
+ `FieldPath.documentId(), you must provide a plain document ID, but ` +
+ `'foo/bar/baz' contains a '/' character.`
+ );
+
+ expect(() =>
+ collection.where(FieldPath.documentId(), 'in', [1, 2])
+ ).to.throw(
+ 'Invalid query. When querying with FieldPath.documentId(), you must ' +
+ 'provide a valid string or a DocumentReference, but it was: 1.'
+ );
+
+ expect(() =>
+ db.collectionGroup('foo').where(FieldPath.documentId(), 'in', ['foo'])
+ ).to.throw(
+ `Invalid query. When querying a collection group by ` +
+ `FieldPath.documentId(), the value provided must result in a valid document path, ` +
+ `but 'foo' is not because it has an odd number of segments (1).`
+ );
+ }
+ );
+
+ validationIt(persistence, 'cannot pass undefined as a field value', db => {
+ const collection = db.collection('test');
+ expect(() => collection.where('foo', '==', undefined)).to.throw(
+ 'Function Query.where() called with invalid data. Unsupported field value: undefined'
+ );
+ expect(() => collection.orderBy('foo').startAt(undefined)).to.throw(
+ 'Function Query.startAt() called with invalid data. Unsupported field value: undefined'
+ );
+ });
+ });
+});
+
+function expectSetToFail(
+ db: firestore.FirebaseFirestore,
+ data: any,
+ reason: string
+): Promise {
+ return expectWriteToFail(
+ db,
+ data,
+ reason,
+ /*includeSets=*/ true,
+ /*includeUpdates=*/ false
+ );
+}
+
+function expectUpdateToFail(
+ db: firestore.FirebaseFirestore,
+ data: any,
+ reason: string
+): Promise {
+ return expectWriteToFail(
+ db,
+ data,
+ reason,
+ /*includeSets=*/ false,
+ /*includeUpdates=*/ true
+ );
+}
+
+/**
+ * Performs a write using each set and/or update API and makes sure it fails
+ * with the expected reason.
+ */
+function expectWriteToFail(
+ db: firestore.FirebaseFirestore,
+ data: any,
+ reason: string,
+ includeSets?: boolean,
+ includeUpdates?: boolean
+): Promise {
+ if (includeSets === undefined) {
+ includeSets = true;
+ }
+ if (includeUpdates === undefined) {
+ includeUpdates = true;
+ }
+
+ const docPath = 'foo/bar';
+ if (reason.includes('in field')) {
+ reason = `${reason.slice(0, -1)} in document ${docPath})`;
+ } else {
+ reason = `${reason} (found in document ${docPath})`;
+ }
+
+ const docRef = db.doc(docPath);
+ const error = (fnName: string): string =>
+ `Function ${fnName}() called with invalid data. ${reason}`;
+
+ if (includeSets) {
+ expect(() => docRef.set(data)).to.throw(error('DocumentReference.set'));
+ expect(() => docRef.firestore.batch().set(docRef, data)).to.throw(
+ error('WriteBatch.set')
+ );
+ }
+
+ if (includeUpdates) {
+ expect(() => docRef.update(data)).to.throw(
+ error('DocumentReference.update')
+ );
+ expect(() => docRef.firestore.batch().update(docRef, data)).to.throw(
+ error('WriteBatch.update')
+ );
+ }
+
+ return docRef.firestore.runTransaction(async txn => {
+ if (includeSets) {
+ expect(() => txn.set(docRef, data)).to.throw(error('Transaction.set'));
+ }
+
+ if (includeUpdates) {
+ expect(() => txn.update(docRef, data)).to.throw(
+ error('Transaction.update')
+ );
+ }
+ });
+}
+
+/**
+ * Tests a field path with all of our APIs that accept field paths and ensures
+ * they fail with the specified reason.
+ */
+function expectFieldPathToFail(
+ snapshot: firestore.DocumentSnapshot,
+ path: string,
+ reason: string
+): Promise {
+ // Get an arbitrary snapshot we can use for testing.
+ return Promise.resolve().then(() => {
+ // Snapshot paths.
+ expect(() => snapshot.get(path)).to.throw(
+ 'Function DocumentSnapshot.get() called with invalid data. ' + reason
+ );
+
+ const db = snapshot.ref.firestore;
+
+ // Query filter / order fields.
+ const coll = db.collection('test-collection');
+ // <=, etc omitted for brevity since the code path is trivially
+ // shared.
+ expect(() => coll.where(path, '==', 1)).to.throw(
+ `Function Query.where() called with invalid data. ` + reason
+ );
+ expect(() => coll.orderBy(path)).to.throw(
+ `Function Query.orderBy() called with invalid data. ` + reason
+ );
+
+ // Update paths.
+ const data = {} as { [field: string]: number };
+ data[path] = 1;
+ return expectUpdateToFail(db, data, reason);
+ });
+}