From 8aedc63138827ea03a8dacae4e7bccdf048a2be7 Mon Sep 17 00:00:00 2001 From: Mark Duckworth <1124037+MarkDuckworth@users.noreply.github.com> Date: Wed, 15 Feb 2023 16:54:11 -0700 Subject: [PATCH] fix: emulator support for system tests (#1813) fix: emulator support for system tests. Run system tests against the emulator using: `yarn system-test:grpc:emulator` or `yarn system-test:rest:emulator` --- dev/system-test/firestore.ts | 532 +++++++++++++++++++---------------- package.json | 2 + 2 files changed, 291 insertions(+), 243 deletions(-) diff --git a/dev/system-test/firestore.ts b/dev/system-test/firestore.ts index deb9454f8..d16c35e4c 100644 --- a/dev/system-test/firestore.ts +++ b/dev/system-test/firestore.ts @@ -208,123 +208,128 @@ describe('Firestore class', () => { }); }); -describe('CollectionGroup class', () => { - const desiredPartitionCount = 3; - const documentCount = 2 * 128 + 127; // Minimum partition size is 128. +// Skip partition query tests when running against the emulator because +// partition queries are not supported by the emulator. +(process.env.FIRESTORE_EMULATOR_HOST === undefined ? describe : describe.skip)( + 'CollectionGroup class', + () => { + const desiredPartitionCount = 3; + const documentCount = 2 * 128 + 127; // Minimum partition size is 128. - let firestore: Firestore; - let randomColl: CollectionReference; - let collectionGroup: CollectionGroup; + let firestore: Firestore; + let randomColl: CollectionReference; + let collectionGroup: CollectionGroup; - before(async () => { - randomColl = getTestRoot(); - firestore = randomColl.firestore; - collectionGroup = firestore.collectionGroup(randomColl.id); + before(async () => { + randomColl = getTestRoot(); + firestore = randomColl.firestore; + collectionGroup = firestore.collectionGroup(randomColl.id); - const batch = firestore.batch(); - for (let i = 0; i < documentCount; ++i) { - batch.create(randomColl.doc(), {title: 'post', author: 'author'}); - } - await batch.commit(); - }); + const batch = firestore.batch(); + for (let i = 0; i < documentCount; ++i) { + batch.create(randomColl.doc(), {title: 'post', author: 'author'}); + } + await batch.commit(); + }); - async function getPartitions( - collectionGroup: CollectionGroup, - desiredPartitionsCount: number - ): Promise[]> { - const partitions: QueryPartition[] = []; - for await (const partition of collectionGroup.getPartitions( - desiredPartitionsCount - )) { - partitions.push(partition); + async function getPartitions( + collectionGroup: CollectionGroup, + desiredPartitionsCount: number + ): Promise[]> { + const partitions: QueryPartition[] = []; + for await (const partition of collectionGroup.getPartitions( + desiredPartitionsCount + )) { + partitions.push(partition); + } + return partitions; } - return partitions; - } - async function verifyPartitions( - partitions: QueryPartition[] - ): Promise[]> { - expect(partitions.length).to.not.be.greaterThan(desiredPartitionCount); - - expect(partitions[0].startAt).to.be.undefined; - for (let i = 0; i < partitions.length - 1; ++i) { - // The cursor value is a single DocumentReference - expect( - (partitions[i].endBefore![0] as DocumentReference).isEqual( - partitions[i + 1].startAt![0] as DocumentReference - ) - ).to.be.true; - } - expect(partitions[partitions.length - 1].endBefore).to.be.undefined; + async function verifyPartitions( + partitions: QueryPartition[] + ): Promise[]> { + expect(partitions.length).to.not.be.greaterThan(desiredPartitionCount); - // Validate that we can use the partitions to read the original documents. - const documents: QueryDocumentSnapshot[] = []; - for (const partition of partitions) { - documents.push(...(await partition.toQuery().get()).docs); - } - expect(documents.length).to.equal(documentCount); + expect(partitions[0].startAt).to.be.undefined; + for (let i = 0; i < partitions.length - 1; ++i) { + // The cursor value is a single DocumentReference + expect( + (partitions[i].endBefore![0] as DocumentReference).isEqual( + partitions[i + 1].startAt![0] as DocumentReference + ) + ).to.be.true; + } + expect(partitions[partitions.length - 1].endBefore).to.be.undefined; - return documents; - } + // Validate that we can use the partitions to read the original documents. + const documents: QueryDocumentSnapshot[] = []; + for (const partition of partitions) { + documents.push(...(await partition.toQuery().get()).docs); + } + expect(documents.length).to.equal(documentCount); - it('partition query', async () => { - const partitions = await getPartitions( - collectionGroup, - desiredPartitionCount - ); - await verifyPartitions(partitions); - }); + return documents; + } - it('partition query with manual cursors', async () => { - const partitions = await getPartitions( - collectionGroup, - desiredPartitionCount - ); + it('partition query', async () => { + const partitions = await getPartitions( + collectionGroup, + desiredPartitionCount + ); + await verifyPartitions(partitions); + }); - const documents: QueryDocumentSnapshot[] = []; - for (const partition of partitions) { - let partitionedQuery: Query = collectionGroup; - if (partition.startAt) { - partitionedQuery = partitionedQuery.startAt(...partition.startAt); - } - if (partition.endBefore) { - partitionedQuery = partitionedQuery.endBefore(...partition.endBefore); + it('partition query with manual cursors', async () => { + const partitions = await getPartitions( + collectionGroup, + desiredPartitionCount + ); + + const documents: QueryDocumentSnapshot[] = []; + for (const partition of partitions) { + let partitionedQuery: Query = collectionGroup; + if (partition.startAt) { + partitionedQuery = partitionedQuery.startAt(...partition.startAt); + } + if (partition.endBefore) { + partitionedQuery = partitionedQuery.endBefore(...partition.endBefore); + } + documents.push(...(await partitionedQuery.get()).docs); } - documents.push(...(await partitionedQuery.get()).docs); - } - expect(documents.length).to.equal(documentCount); - }); + expect(documents.length).to.equal(documentCount); + }); - it('partition query with converter', async () => { - const collectionGroupWithConverter = - collectionGroup.withConverter(postConverter); - const partitions = await getPartitions( - collectionGroupWithConverter, - desiredPartitionCount - ); - const documents = await verifyPartitions(partitions); + it('partition query with converter', async () => { + const collectionGroupWithConverter = + collectionGroup.withConverter(postConverter); + const partitions = await getPartitions( + collectionGroupWithConverter, + desiredPartitionCount + ); + const documents = await verifyPartitions(partitions); - for (const document of documents) { - expect(document.data()).to.be.an.instanceOf(Post); - } - }); + for (const document of documents) { + expect(document.data()).to.be.an.instanceOf(Post); + } + }); - it('empty partition query', async () => { - const desiredPartitionCount = 3; + it('empty partition query', async () => { + const desiredPartitionCount = 3; - const collectionGroupId = randomColl.doc().id; - const collectionGroup = firestore.collectionGroup(collectionGroupId); - const partitions = await getPartitions( - collectionGroup, - desiredPartitionCount - ); + const collectionGroupId = randomColl.doc().id; + const collectionGroup = firestore.collectionGroup(collectionGroupId); + const partitions = await getPartitions( + collectionGroup, + desiredPartitionCount + ); - expect(partitions.length).to.equal(1); - expect(partitions[0].startAt).to.be.undefined; - expect(partitions[0].endBefore).to.be.undefined; - }); -}); + expect(partitions.length).to.equal(1); + expect(partitions[0].startAt).to.be.undefined; + expect(partitions[0].endBefore).to.be.undefined; + }); + } +); describe('CollectionReference class', () => { let firestore: Firestore; @@ -711,13 +716,18 @@ describe('DocumentReference class', () => { }); }); - it('enforces that updated document exists', () => { - return randomCol - .doc() - .update({foo: 'b'}) - .catch(err => { - expect(err.message).to.match(/No document to update/); - }); + it('enforces that updated document exists', async () => { + const promise = randomCol.doc().update({foo: 'b'}); + + // Validate the error message when testing against the firestore backend. + if (process.env.FIRESTORE_EMULATOR_HOST === undefined) { + await expect(promise).to.eventually.be.rejectedWith( + /No document to update/ + ); + } else { + // The emulator generates a different error message, do not validate the error message. + await expect(promise).to.eventually.be.rejected; + } }); it('has delete() method', () => { @@ -744,14 +754,21 @@ describe('DocumentReference class', () => { return ref.delete(); }); - it('will fail to delete document with exists: true if doc does not exist', () => { + it('will fail to delete document with exists: true if doc does not exist', async () => { const ref = randomCol.doc(); - return ref + const promise = ref .delete({exists: true}) - .then(() => Promise.reject('Delete should have failed')) - .catch((err: Error) => { - expect(err.message).to.contain('No document to update'); - }); + .then(() => Promise.reject('Delete should have failed')); + + // Validate the error message when testing against the firestore backend. + if (process.env.FIRESTORE_EMULATOR_HOST === undefined) { + await expect(promise).to.eventually.be.rejectedWith( + /No document to update/ + ); + } else { + // The emulator generates a different error message, do not validate the error message. + await expect(promise).to.eventually.be.rejected; + } }); it('supports non-alphanumeric field names', () => { @@ -2472,51 +2489,64 @@ describe('Transaction class', () => { let attempts = 0; - await expect( - firestore.runTransaction(async transaction => { - ++attempts; - transaction.update(ref, {foo: 'b'}); - }) - ).to.eventually.be.rejectedWith('No document to update'); + const promise = firestore.runTransaction(async transaction => { + ++attempts; + transaction.update(ref, {foo: 'b'}); + }); + + // Validate the error message when testing against the firestore backend. + if (process.env.FIRESTORE_EMULATOR_HOST === undefined) { + await expect(promise).to.eventually.be.rejectedWith( + /No document to update/ + ); + } else { + // The emulator generates a different error message, do not validate the error message. + await expect(promise).to.eventually.be.rejected; + } expect(attempts).to.equal(1); }); - it('retries transactions that fail with contention', async () => { - const ref = randomCol.doc('doc'); + // Skip this test when running against the emulator because it does not work + // against the emulator. Contention in the emulator may behave differently. + (process.env.FIRESTORE_EMULATOR_HOST === undefined ? it : it.skip)( + 'retries transactions that fail with contention', + async () => { + const ref = randomCol.doc('doc'); - let attempts = 0; + let attempts = 0; - // Create two transactions that both read and update the same document. - // `contentionPromise` is used to ensure that both transactions are active - // on commit, which causes one of transactions to fail with Code ABORTED - // and be retried. - const contentionPromise = [new Deferred(), new Deferred()]; + // Create two transactions that both read and update the same document. + // `contentionPromise` is used to ensure that both transactions are active + // on commit, which causes one of transactions to fail with Code ABORTED + // and be retried. + const contentionPromise = [new Deferred(), new Deferred()]; - const firstTransaction = firestore.runTransaction(async transaction => { - ++attempts; - await transaction.get(ref); - contentionPromise[0].resolve(); - await contentionPromise[1].promise; - transaction.set(ref, {first: true}, {merge: true}); - }); + const firstTransaction = firestore.runTransaction(async transaction => { + ++attempts; + await transaction.get(ref); + contentionPromise[0].resolve(); + await contentionPromise[1].promise; + transaction.set(ref, {first: true}, {merge: true}); + }); - const secondTransaction = firestore.runTransaction(async transaction => { - ++attempts; - await transaction.get(ref); - contentionPromise[1].resolve(); - await contentionPromise[0].promise; - transaction.set(ref, {second: true}, {merge: true}); - }); + const secondTransaction = firestore.runTransaction(async transaction => { + ++attempts; + await transaction.get(ref); + contentionPromise[1].resolve(); + await contentionPromise[0].promise; + transaction.set(ref, {second: true}, {merge: true}); + }); - await firstTransaction; - await secondTransaction; + await firstTransaction; + await secondTransaction; - expect(attempts).to.equal(3); + expect(attempts).to.equal(3); - const finalSnapshot = await ref.get(); - expect(finalSnapshot.data()).to.deep.equal({first: true, second: true}); - }); + const finalSnapshot = await ref.get(); + expect(finalSnapshot.data()).to.deep.equal({first: true, second: true}); + } + ); it('supports read-only transactions', async () => { const ref = randomCol.doc('doc'); @@ -2540,24 +2570,29 @@ describe('Transaction class', () => { expect(snapshot.get('foo')).to.equal(1); }); - it('fails read-only with writes', async () => { - let attempts = 0; + // Skip this test when running against the emulator because it does not work + // against the emulator. The emulator fails to enforce read-only transactions. + (process.env.FIRESTORE_EMULATOR_HOST === undefined ? it : it.skip)( + 'fails read-only with writes', + async () => { + let attempts = 0; - const ref = randomCol.doc('doc'); - try { - await firestore.runTransaction( - async updateFunction => { - ++attempts; - updateFunction.set(ref, {}); - }, - {readOnly: true} - ); - expect.fail(); - } catch (e) { - expect(attempts).to.equal(1); - expect(e.code).to.equal(Status.INVALID_ARGUMENT); + const ref = randomCol.doc('doc'); + try { + await firestore.runTransaction( + async updateFunction => { + ++attempts; + updateFunction.set(ref, {}); + }, + {readOnly: true} + ); + expect.fail(); + } catch (e) { + expect(attempts).to.equal(1); + expect(e.code).to.equal(Status.INVALID_ARGUMENT); + } } - }); + ); }); describe('WriteBatch class', () => { @@ -2972,91 +3007,102 @@ describe('BulkWriter class', () => { }); describe('Client initialization', () => { - const ops: Array<[string, (coll: CollectionReference) => Promise]> = + const ops: Array< [ - ['CollectionReference.get()', randomColl => randomColl.get()], - ['CollectionReference.add()', randomColl => randomColl.add({})], - [ - 'CollectionReference.stream()', - randomColl => { - const deferred = new Deferred(); - randomColl.stream().on('finish', () => { - deferred.resolve(); - }); - return deferred.promise; - }, - ], - [ - 'CollectionReference.listDocuments()', - randomColl => randomColl.listDocuments(), - ], - [ - 'CollectionReference.onSnapshot()', - randomColl => { - const deferred = new Deferred(); - const unsubscribe = randomColl.onSnapshot(() => { - unsubscribe(); - deferred.resolve(); - }); - return deferred.promise; - }, - ], - ['DocumentReference.get()', randomColl => randomColl.doc().get()], - ['DocumentReference.create()', randomColl => randomColl.doc().create({})], - ['DocumentReference.set()', randomColl => randomColl.doc().set({})], - [ - 'DocumentReference.update()', - async randomColl => { - const update = randomColl.doc().update('foo', 'bar'); + string, + (coll: CollectionReference) => Promise, + /* skip */ boolean? + ] + > = [ + ['CollectionReference.get()', randomColl => randomColl.get()], + ['CollectionReference.add()', randomColl => randomColl.add({})], + [ + 'CollectionReference.stream()', + randomColl => { + const deferred = new Deferred(); + randomColl.stream().on('finish', () => { + deferred.resolve(); + }); + return deferred.promise; + }, + ], + [ + 'CollectionReference.listDocuments()', + randomColl => randomColl.listDocuments(), + ], + [ + 'CollectionReference.onSnapshot()', + randomColl => { + const deferred = new Deferred(); + const unsubscribe = randomColl.onSnapshot(() => { + unsubscribe(); + deferred.resolve(); + }); + return deferred.promise; + }, + ], + ['DocumentReference.get()', randomColl => randomColl.doc().get()], + ['DocumentReference.create()', randomColl => randomColl.doc().create({})], + ['DocumentReference.set()', randomColl => randomColl.doc().set({})], + [ + 'DocumentReference.update()', + async randomColl => { + const update = randomColl.doc().update('foo', 'bar'); + + // Don't validate the error message when running against the emulator. + // Emulator gives different error message. + if (process.env.FIRESTORE_EMULATOR_HOST === undefined) { await expect(update).to.eventually.be.rejectedWith( 'No document to update' ); - }, - ], - ['DocumentReference.delete()', randomColl => randomColl.doc().delete()], - [ - 'DocumentReference.listCollections()', - randomColl => randomColl.doc().listCollections(), - ], - [ - 'DocumentReference.onSnapshot()', - randomColl => { - const deferred = new Deferred(); - const unsubscribe = randomColl.doc().onSnapshot(() => { - unsubscribe(); - deferred.resolve(); - }); - return deferred.promise; - }, - ], - [ - 'CollectionGroup.getPartitions()', - async randomColl => { - const partitions = randomColl.firestore - .collectionGroup('id') - .getPartitions(2); - // eslint-disable-next-line @typescript-eslint/no-unused-vars - for await (const _ of partitions); - }, - ], - [ - 'Firestore.runTransaction()', - randomColl => - randomColl.firestore.runTransaction(t => t.get(randomColl)), - ], - [ - 'Firestore.getAll()', - randomColl => randomColl.firestore.getAll(randomColl.doc()), - ], - [ - 'Firestore.batch()', - randomColl => randomColl.firestore.batch().commit(), - ], - ['Firestore.terminate()', randomColl => randomColl.firestore.terminate()], - ]; - - for (const [description, op] of ops) { - it(`succeeds for ${description}`, () => { + } else { + await expect(update).to.eventually.be.rejected; + } + }, + ], + ['DocumentReference.delete()', randomColl => randomColl.doc().delete()], + [ + 'DocumentReference.listCollections()', + randomColl => randomColl.doc().listCollections(), + ], + [ + 'DocumentReference.onSnapshot()', + randomColl => { + const deferred = new Deferred(); + const unsubscribe = randomColl.doc().onSnapshot(() => { + unsubscribe(); + deferred.resolve(); + }); + return deferred.promise; + }, + ], + [ + 'CollectionGroup.getPartitions()', + async randomColl => { + const partitions = randomColl.firestore + .collectionGroup('id') + .getPartitions(2); + // eslint-disable-next-line @typescript-eslint/no-unused-vars + for await (const _ of partitions); + }, + // Skip this test when running against the emulator because partition queries + // are not supported in the emulator. + !!process.env.FIRESTORE_EMULATOR_HOST, + ], + [ + 'Firestore.runTransaction()', + randomColl => randomColl.firestore.runTransaction(t => t.get(randomColl)), + ], + [ + 'Firestore.getAll()', + randomColl => randomColl.firestore.getAll(randomColl.doc()), + ], + ['Firestore.batch()', randomColl => randomColl.firestore.batch().commit()], + ['Firestore.terminate()', randomColl => randomColl.firestore.terminate()], + ]; + + for (const [description, op, skip] of ops) { + (!skip ? it : it.skip)(`succeeds for ${description}`, () => { const randomCol = getTestRoot(); return op(randomCol); }); diff --git a/package.json b/package.json index a36b6eb1f..1b1c9cc17 100644 --- a/package.json +++ b/package.json @@ -32,6 +32,8 @@ "docs": "jsdoc -c .jsdoc.js", "system-test:rest": "USE_REST_FALLBACK=YES mocha build/system-test --timeout 600000", "system-test:grpc": "mocha build/system-test --timeout 600000", + "system-test:rest:emulator": "USE_REST_FALLBACK=YES FIRESTORE_EMULATOR_HOST=localhost:8080 mocha build/system-test --timeout 600000", + "system-test:grpc:emulator": "FIRESTORE_EMULATOR_HOST=localhost:8080 mocha build/system-test --timeout 600000", "system-test": "npm run system-test:grpc && npm run system-test:rest", "presystem-test": "npm run compile", "samples-test": "npm link && cd samples/ && npm link ../ && npm test && cd ../",