From b5084fbbfec356dcc95ac34bd59df1809979d6c1 Mon Sep 17 00:00:00 2001 From: Sebastian Schmidt Date: Mon, 10 Jan 2022 12:34:53 -0700 Subject: [PATCH] Add explicit tests for Compat (#5870) --- .../Integration_Tests__Emulator_.xml | 18 + packages/firestore-compat/babel-register.js | 18 + packages/firestore-compat/babel.config.json | 7 + packages/firestore-compat/karma.conf.js | 20 +- packages/firestore-compat/package.json | 10 +- .../test/array_transforms.test.ts | 240 +++ .../test/batch_writes.test.ts | 378 ++++ packages/firestore-compat/test/bootstrap.ts | 31 + packages/firestore-compat/test/bundle.test.ts | 252 +++ packages/firestore-compat/test/cursor.test.ts | 312 +++ .../firestore-compat/test/database.test.ts | 1690 +++++++++++++++++ packages/firestore-compat/test/fields.test.ts | 453 +++++ .../firestore-compat/test/get_options.test.ts | 606 ++++++ .../test/numeric_transforms.test.ts | 224 +++ packages/firestore-compat/test/query.test.ts | 1216 ++++++++++++ .../test/server_timestamp.test.ts | 299 +++ packages/firestore-compat/test/smoke.test.ts | 169 ++ .../test/transactions.test.ts | 663 +++++++ packages/firestore-compat/test/type.test.ts | 171 ++ .../test/util/equality_matcher.ts | 177 ++ .../test/util/events_accumulator.ts | 117 ++ .../test/util/firebase_export.ts | 82 + .../firestore-compat/test/util/helpers.ts | 276 +++ .../firestore-compat/test/util/promise.ts | 38 + .../firestore-compat/test/util/settings.ts | 62 + .../firestore-compat/test/validation.test.ts | 1425 ++++++++++++++ 26 files changed, 8934 insertions(+), 20 deletions(-) create mode 100644 packages/firestore-compat/.idea/runConfigurations/Integration_Tests__Emulator_.xml create mode 100644 packages/firestore-compat/babel-register.js create mode 100644 packages/firestore-compat/babel.config.json create mode 100644 packages/firestore-compat/test/array_transforms.test.ts create mode 100644 packages/firestore-compat/test/batch_writes.test.ts create mode 100644 packages/firestore-compat/test/bootstrap.ts create mode 100644 packages/firestore-compat/test/bundle.test.ts create mode 100644 packages/firestore-compat/test/cursor.test.ts create mode 100644 packages/firestore-compat/test/database.test.ts create mode 100644 packages/firestore-compat/test/fields.test.ts create mode 100644 packages/firestore-compat/test/get_options.test.ts create mode 100644 packages/firestore-compat/test/numeric_transforms.test.ts create mode 100644 packages/firestore-compat/test/query.test.ts create mode 100644 packages/firestore-compat/test/server_timestamp.test.ts create mode 100644 packages/firestore-compat/test/smoke.test.ts create mode 100644 packages/firestore-compat/test/transactions.test.ts create mode 100644 packages/firestore-compat/test/type.test.ts create mode 100644 packages/firestore-compat/test/util/equality_matcher.ts create mode 100644 packages/firestore-compat/test/util/events_accumulator.ts create mode 100644 packages/firestore-compat/test/util/firebase_export.ts create mode 100644 packages/firestore-compat/test/util/helpers.ts create mode 100644 packages/firestore-compat/test/util/promise.ts create mode 100644 packages/firestore-compat/test/util/settings.ts create mode 100644 packages/firestore-compat/test/validation.test.ts 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); + }); +}