From 4a3ea80e6b3e66c5a24799d19bdf1088b0cf60d9 Mon Sep 17 00:00:00 2001 From: Leo Vigna Date: Mon, 6 Nov 2023 16:51:35 +0400 Subject: [PATCH 1/2] add firestore/admin --- firestore/admin/collection/index.ts | 262 ++++++++++++++++++++++++++++ firestore/admin/document/index.ts | 60 +++++++ firestore/admin/fromRef.ts | 35 ++++ firestore/admin/index.ts | 20 +++ firestore/admin/interfaces.ts | 12 ++ firestore/admin/package.json | 8 + package.json | 7 +- 7 files changed, 403 insertions(+), 1 deletion(-) create mode 100644 firestore/admin/collection/index.ts create mode 100644 firestore/admin/document/index.ts create mode 100644 firestore/admin/fromRef.ts create mode 100644 firestore/admin/index.ts create mode 100644 firestore/admin/interfaces.ts create mode 100644 firestore/admin/package.json diff --git a/firestore/admin/collection/index.ts b/firestore/admin/collection/index.ts new file mode 100644 index 0000000..9ae3861 --- /dev/null +++ b/firestore/admin/collection/index.ts @@ -0,0 +1,262 @@ +/** + * @license + * Copyright 2023 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 { fromRef } from '../fromRef'; +import { + Observable, + MonoTypeOperatorFunction, + OperatorFunction, + pipe, + UnaryFunction, + from, +} from 'rxjs'; +import { + map, + filter, + scan, + distinctUntilChanged, + startWith, + pairwise, +} from 'rxjs/operators'; +import { snapToData } from '../document'; +import { DocumentChangeType, DocumentChange, Query, QueryDocumentSnapshot, QuerySnapshot, DocumentData } from '../interfaces'; +import { CountSnapshot } from '../interfaces'; +const ALL_EVENTS: DocumentChangeType[] = ['added', 'modified', 'removed']; + +/** + * Create an operator that determines if a the stream of document changes + * are specified by the event filter. If the document change type is not + * in specified events array, it will not be emitted. + */ +const filterEvents = ( + events?: DocumentChangeType[], +): MonoTypeOperatorFunction[]> => + filter((changes: DocumentChange[]) => { + let hasChange = false; + for (let i = 0; i < changes.length; i++) { + const change = changes[i]; + if (events && events.indexOf(change.type) >= 0) { + hasChange = true; + break; + } + } + return hasChange; + }); + +/** + * Splice arguments on top of a sliced array, to break top-level === + * this is useful for change-detection + */ +function sliceAndSplice( + original: T[], + start: number, + deleteCount: number, + ...args: T[] +): T[] { + const returnArray = original.slice(); + returnArray.splice(start, deleteCount, ...args); + return returnArray; +} + +/** + * Creates a new sorted array from a new change. + * @param combined + * @param change + */ +function processIndividualChange( + combined: DocumentChange[], + change: DocumentChange, +): DocumentChange[] { + switch (change.type) { + case 'added': + if ( + combined[change.newIndex] && + combined[change.newIndex].doc.ref.isEqual(change.doc.ref) + ) { + // Skip duplicate emissions. This is rare. + // TODO: Investigate possible bug in SDK. + } else { + return sliceAndSplice(combined, change.newIndex, 0, change); + } + break; + case 'modified': + if ( + combined[change.oldIndex] == null || + combined[change.oldIndex].doc.ref.isEqual(change.doc.ref) + ) { + // When an item changes position we first remove it + // and then add it's new position + if (change.oldIndex !== change.newIndex) { + const copiedArray = combined.slice(); + copiedArray.splice(change.oldIndex, 1); + copiedArray.splice(change.newIndex, 0, change); + return copiedArray; + } else { + return sliceAndSplice(combined, change.newIndex, 1, change); + } + } + break; + case 'removed': + if ( + combined[change.oldIndex] && + combined[change.oldIndex].doc.ref.isEqual(change.doc.ref) + ) { + return sliceAndSplice(combined, change.oldIndex, 1); + } + break; + default: // ignore + } + return combined; +} + +/** + * Combines the total result set from the current set of changes from an incoming set + * of changes. + * @param current + * @param changes + * @param events + */ +function processDocumentChanges( + current: DocumentChange[], + changes: DocumentChange[], + events: DocumentChangeType[] = ALL_EVENTS, +): DocumentChange[] { + changes.forEach((change) => { + // skip unwanted change types + if (events.indexOf(change.type) > -1) { + current = processIndividualChange(current, change); + } + }); + return current; +} + +/** + * Create an operator that allows you to compare the current emission with + * the prior, even on first emission (where prior is undefined). + */ +const windowwise = () => + pipe( + startWith(undefined), + pairwise() as OperatorFunction, + ); + +/** + * Create an operator that filters out empty changes. We provide the + * ability to filter on events, which means all changes can be filtered out. + * This creates an empty array and would be incorrect to emit. + */ +const filterEmptyUnlessFirst = (): UnaryFunction< + Observable, + Observable +> => + pipe( + windowwise(), + filter(([prior, current]) => current.length > 0 || prior === undefined), + map(([, current]) => current), + ); + +/** + * Return a stream of document changes on a query. These results are not in sort order but in + * order of occurence. + * @param query + */ +export function collectionChanges( + query: Query, + options: { + events?: DocumentChangeType[] + } = {}, +): Observable[]> { + return fromRef(query).pipe( + map((currentSnapshot) => { + const docChanges = currentSnapshot.docChanges(); + return docChanges; + }), + filterEvents(options.events || ALL_EVENTS), + filterEmptyUnlessFirst(), + ); +} + +/** + * Return a stream of document snapshots on a query. These results are in sort order. + * @param query + */ +export function collection(query: Query): Observable[]> { + return fromRef(query).pipe( + map((changes) => changes.docs), + ); +} + +/** + * Return a stream of document changes on a query. These results are in sort order. + * @param query + */ +export function sortedChanges( + query: Query, + options: { + events?: DocumentChangeType[] + } = {}, +): Observable[]> { + return collectionChanges(query, options).pipe( + scan( + (current: DocumentChange[], changes: DocumentChange[]) => + processDocumentChanges(current, changes, options.events), + [], + ), + distinctUntilChanged(), + ); +} + +/** + * Create a stream of changes as they occur it time. This method is similar + * to docChanges() but it collects each event in an array over time. + */ +export function auditTrail( + query: Query, + options: { + events?: DocumentChangeType[] + } = {}, +): Observable[]> { + return collectionChanges(query, options).pipe( + scan((current, action) => [...current, ...action], [] as DocumentChange[]), + ); +} + +/** + * Returns a stream of documents mapped to their data payload, and optionally the document ID + * @param query + * @param options + */ +export function collectionData( + query: Query, + options: { + idField?: ((U | keyof T) & keyof NonNullable), + }, +): Observable<((T & { [T in U]: string; }) | NonNullable)[]> { + return collection(query).pipe( + map((arr) => { + return arr.map((snap) => snapToData(snap, options)!); + }), + ); +} + +export function collectionCountSnap(query: Query): Observable { + return from(query.count().get()); +} + +export function collectionCount(query: Query): Observable { + return collectionCountSnap(query).pipe(map((snap) => snap.data().count)); +} diff --git a/firestore/admin/document/index.ts b/firestore/admin/document/index.ts new file mode 100644 index 0000000..0c820c1 --- /dev/null +++ b/firestore/admin/document/index.ts @@ -0,0 +1,60 @@ +/** + * @license + * Copyright 2023 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. + */ + +// TODO fix the import +import { DocumentReference, DocumentSnapshot, DocumentData } from '../interfaces'; +import { fromRef } from '../fromRef'; +import { map } from 'rxjs/operators'; +import { Observable } from 'rxjs'; + +export function doc(ref: DocumentReference): Observable> { + return fromRef(ref); +} + +/** + * Returns a stream of a document, mapped to its data payload and optionally the document ID + * @param query + * @param options + */ +export function docData( + ref: DocumentReference, + options: { + idField?: keyof R, + }, +): Observable { + return doc(ref).pipe(map((snap) => snapToData(snap, options))); +} + +export function snapToData( + snapshot: DocumentSnapshot, + options: { + idField?: keyof R, + }, +): T | R | undefined { + const data = snapshot.data(); + + // match the behavior of the JS SDK when the snapshot doesn't exist + // it's possible with data converters too that the user didn't return an object + if (!snapshot.exists || typeof data !== 'object' || data === null || !options.idField) { + return data; + } + + return { + ...data, + [options.idField]: snapshot.id, + }; +} diff --git a/firestore/admin/fromRef.ts b/firestore/admin/fromRef.ts new file mode 100644 index 0000000..beb927a --- /dev/null +++ b/firestore/admin/fromRef.ts @@ -0,0 +1,35 @@ +/** + * @license + * Copyright 2018 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 { Observable } from 'rxjs'; +import { DocumentReference, DocumentData, Query, DocumentSnapshot, QuerySnapshot } from './interfaces'; + +/* eslint-disable @typescript-eslint/no-explicit-any */ +export function fromRef(ref: DocumentReference): Observable>; +export function fromRef(ref: Query): Observable>; +export function fromRef( + ref: DocumentReference | Query, +): Observable { + /* eslint-enable @typescript-eslint/no-explicit-any */ + return new Observable((subscriber) => { + const unsubscribe = ref.onSnapshot( + subscriber.next.bind(subscriber), + subscriber.error.bind(subscriber), + ); + return { unsubscribe }; + }); +} diff --git a/firestore/admin/index.ts b/firestore/admin/index.ts new file mode 100644 index 0000000..7a899b1 --- /dev/null +++ b/firestore/admin/index.ts @@ -0,0 +1,20 @@ +/** + * @license + * Copyright 2018 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 * from './collection'; +export * from './document'; +export * from './fromRef'; diff --git a/firestore/admin/interfaces.ts b/firestore/admin/interfaces.ts new file mode 100644 index 0000000..a66abdb --- /dev/null +++ b/firestore/admin/interfaces.ts @@ -0,0 +1,12 @@ +export type DocumentReference = import('firebase-admin/firestore').DocumentReference; +export type DocumentData = import('firebase-admin/firestore').DocumentData; +//export type SnapshotListenOptions = import('firebase-admin/firestore').SnapshotListenOptions; +export type Query = import('firebase-admin/firestore').Query; +export type DocumentSnapshot = import('firebase-admin/firestore').DocumentSnapshot; +export type QuerySnapshot = import('firebase-admin/firestore').QuerySnapshot; +export type DocumentChangeType = import('firebase-admin/firestore').DocumentChangeType; +export type DocumentChange = import('firebase-admin/firestore').DocumentChange; +export type QueryDocumentSnapshot = import('firebase-admin/firestore').QueryDocumentSnapshot; +export type CountSnapshot = import('@google-cloud/firestore').AggregateQuerySnapshot<{ + count: import('@google-cloud/firestore').AggregateField; +}>; diff --git a/firestore/admin/package.json b/firestore/admin/package.json new file mode 100644 index 0000000..62289bc --- /dev/null +++ b/firestore/admin/package.json @@ -0,0 +1,8 @@ +{ + "name": "rxfire/firestore/lite", + "browser": "../../dist/firestore/lite/index.esm.js", + "main": "../../dist/firestore/lite/index.cjs.js", + "module": "../../dist/firestore/lite/index.esm.js", + "typings": "../../dist/firestore/lite/index.d.ts", + "sideEffects": false +} diff --git a/package.json b/package.json index accf8fb..7312ca7 100644 --- a/package.json +++ b/package.json @@ -27,6 +27,10 @@ "import": "./dist/firestore/index.esm.js", "require": "./dist/firestore/index.cjs.js" }, + "./firestore/admin": { + "import": "./dist/firestore/admin/index.esm.js", + "require": "./dist/firestore/admin/index.cjs.js" + }, "./firestore/lite": { "import": "./dist/firestore/lite/index.esm.js", "require": "./dist/firestore/lite/index.cjs.js" @@ -78,9 +82,9 @@ "emulators": "firebase emulators:start --project=rxfire-test-c497c", "jest": "jest --detectOpenHandles" }, - "dependencies": {}, "peerDependencies": { "firebase": "^9.0.0 || ^10.0.0", + "firebase-admin": "^11.0.0", "rxjs": "^6.0.0 || ^7.0.0" }, "devDependencies": { @@ -98,6 +102,7 @@ "eslint": "^7.32.0", "eslint-config-google": "^0.14.0", "firebase": "^10.0.0", + "firebase-admin": "^11.0.0", "firebase-tools": "^12.5.2", "glob": "^7.1.6", "jest": "^29.6.4", From c0b9c10a02d2f3bdeffb2c9d7dea1a94bb16e9c5 Mon Sep 17 00:00:00 2001 From: Leo Vigna Date: Mon, 6 Nov 2023 18:18:04 +0400 Subject: [PATCH 2/2] add firestore/admin tests --- firestore/admin/collection/index.ts | 2 +- firestore/admin/document/index.ts | 4 +- firestore/admin/package.json | 10 +- test/firestore-admin.test.ts | 448 ++++++++++++++++++++++++++++ 4 files changed, 456 insertions(+), 8 deletions(-) create mode 100644 test/firestore-admin.test.ts diff --git a/firestore/admin/collection/index.ts b/firestore/admin/collection/index.ts index 9ae3861..7d2c58e 100644 --- a/firestore/admin/collection/index.ts +++ b/firestore/admin/collection/index.ts @@ -244,7 +244,7 @@ export function collectionData( query: Query, options: { idField?: ((U | keyof T) & keyof NonNullable), - }, + } = {}, ): Observable<((T & { [T in U]: string; }) | NonNullable)[]> { return collection(query).pipe( map((arr) => { diff --git a/firestore/admin/document/index.ts b/firestore/admin/document/index.ts index 0c820c1..2f69cbd 100644 --- a/firestore/admin/document/index.ts +++ b/firestore/admin/document/index.ts @@ -34,7 +34,7 @@ export function docData( ref: DocumentReference, options: { idField?: keyof R, - }, + } = {}, ): Observable { return doc(ref).pipe(map((snap) => snapToData(snap, options))); } @@ -43,7 +43,7 @@ export function snapToData( snapshot: DocumentSnapshot, options: { idField?: keyof R, - }, + } = {}, ): T | R | undefined { const data = snapshot.data(); diff --git a/firestore/admin/package.json b/firestore/admin/package.json index 62289bc..978c4f5 100644 --- a/firestore/admin/package.json +++ b/firestore/admin/package.json @@ -1,8 +1,8 @@ { - "name": "rxfire/firestore/lite", - "browser": "../../dist/firestore/lite/index.esm.js", - "main": "../../dist/firestore/lite/index.cjs.js", - "module": "../../dist/firestore/lite/index.esm.js", - "typings": "../../dist/firestore/lite/index.d.ts", + "name": "rxfire/firestore/admin", + "browser": "../../dist/firestore/admin/index.esm.js", + "main": "../../dist/firestore/admin/index.cjs.js", + "module": "../../dist/firestore/admin/index.esm.js", + "typings": "../../dist/firestore/admin/index.d.ts", "sideEffects": false } diff --git a/test/firestore-admin.test.ts b/test/firestore-admin.test.ts new file mode 100644 index 0000000..d562b3d --- /dev/null +++ b/test/firestore-admin.test.ts @@ -0,0 +1,448 @@ +/** + * @jest-environment node + */ + +/** + * @license + * Copyright 2023 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. + */ + +/* eslint-disable @typescript-eslint/no-floating-promises */ + +// app is used as namespaces to access types +// eslint-disable-next-line @typescript-eslint/no-unused-vars +import { + collection, + collectionChanges, + sortedChanges, + auditTrail, + docData, + collectionData, + collectionCountSnap, + collectionCount, +} from '../dist/firestore/admin'; +import { map, take, skip, filter } from 'rxjs/operators'; +import { default as TEST_PROJECT, authEmulatorPort, firestoreEmulatorPort, storageEmulatorPort } from './config'; +import { DocumentReference, Firestore as FirebaseFirestore, CollectionReference, getFirestore, DocumentChange, QueryDocumentSnapshot } from 'firebase-admin/firestore'; +import { initializeApp, deleteApp, App as FirebaseApp } from 'firebase-admin/app'; + +const createId = (): string => Math.random().toString(36).substring(5); + +/** + * Create a collection with a random name. This helps sandbox offline tests and + * makes sure tests don't interfere with each other as they run. + */ +const createRandomCol = ( + firestore: FirebaseFirestore, +): CollectionReference => firestore.collection(createId()); + +/** + * Unwrap a snapshot but add the type property to the data object. + */ +const unwrapChange = map((changes: DocumentChange[]) => { + return changes.map((c) => ({ type: c.type, ...c.doc.data() })); +}); + +/** + * Create an environment for the tests to run in. The information is returned + * from the function for use within the test. + */ +const seedTest = (firestore: FirebaseFirestore) => { + const colRef = createRandomCol(firestore); + const davidDoc = colRef.doc('david'); + davidDoc.set({ name: 'David' }); + const shannonDoc = colRef.doc('shannon'); + shannonDoc.set({ name: 'Shannon' }); + const expectedNames = ['David', 'Shannon']; + const expectedEvents = [ + { name: 'David', type: 'added' }, + { name: 'Shannon', type: 'added' }, + ]; + return { colRef, davidDoc, shannonDoc, expectedNames, expectedEvents }; +}; + +describe('RxFire Firestore', () => { + let app: FirebaseApp; + let firestore: FirebaseFirestore; + + /** + * Each test runs inside it's own app instance and the app + * is deleted after the test runs. + * + * Each test is responsible for seeding and removing data. Helper + * functions are useful if the process becomes brittle or tedious. + * Note that removing is less necessary since the tests are run + * against the emulator + */ + beforeEach(() => { + process.env["FIRESTORE_EMULATOR_HOST"] = `127.0.0.1:${firestoreEmulatorPort}`; + process.env["FIREBASE_STORAGE_EMULATOR_HOST"] = `127.0.0.1:${storageEmulatorPort}`; + process.env["FIREBASE_AUTH_EMULATOR_HOST"] = `127.0.0.1:${authEmulatorPort}`; + + app = initializeApp(TEST_PROJECT, createId()); + firestore = getFirestore(app); + }); + + afterEach(async () => { + deleteApp(app).catch(() => undefined); + }); + + describe('collection', () => { + /** + * This is a simple test to see if the collection() method + * correctly emits snapshots. + * + * The test seeds two "people" into the collection. RxFire + * creats an observable with the `collection()` method and + * asserts that the two "people" are in the array. + */ + it('should emit snapshots', (done: jest.DoneCallback) => { + const { colRef, expectedNames } = seedTest(firestore); + + let empty = false; + collection(colRef) + .pipe(map((docs) => docs.map((doc) => doc.data().name))) + .subscribe((names) => { + if (!empty) { + //first + expect(names).toEqual([]); + empty = true; + } else { + expect(names).toEqual(expectedNames); + done(); + } + }); + }); + }); + + describe('collectionChanges', () => { + /** + * The `stateChanges()` method emits a stream of events as they + * occur rather than in sorted order. + * + * This test adds a "person" and then modifies it. This should + * result in an array item of "added" and then "modified". + */ + it('should emit events as they occur', (done: jest.DoneCallback) => { + const { colRef, davidDoc } = seedTest(firestore); + + davidDoc.set({ name: 'David' }); + const firstChange = collectionChanges(colRef).pipe(take(1)); + const secondChange = collectionChanges(colRef).pipe(skip(1)); + + firstChange.subscribe((change) => { + expect(change[0].type).toBe('added'); + davidDoc.update({ name: 'David!' }); + }); + + secondChange.subscribe((change) => { + expect(change[0].type).toBe('modified'); + done(); + }); + }); + }); + + describe('sortedChanges', () => { + /** + * The `sortedChanges()` method reduces the stream of `collectionChanges()` to + * a sorted array. This test seeds two "people" and checks to make sure + * the 'added' change type exists. Afterwards, one "person" is modified. + * The test then checks that the person is modified and in the proper sorted + * order. + */ + it('should emit an array of sorted snapshots', (done: jest.DoneCallback) => { + const { colRef, davidDoc } = seedTest(firestore); + + const addedChanges = sortedChanges(colRef, { events: ['added'] }).pipe(unwrapChange); + + const modifiedChanges = sortedChanges(colRef).pipe( + unwrapChange, + skip(1), + take(1), + ); + + let previousData: Array<{}>; + + addedChanges.subscribe((data) => { + const expectedNames = [ + { name: 'David', type: 'added' }, + { name: 'Shannon', type: 'added' }, + ]; + expect(data).toEqual(expectedNames); + previousData = data; + davidDoc.update({ name: 'David!' }); + }); + + modifiedChanges.subscribe((data) => { + const expectedNames = [ + { name: 'David!', type: 'modified' }, + { name: 'Shannon', type: 'added' }, + ]; + expect(data).toEqual(expectedNames); + expect(data === previousData).toEqual(false); + done(); + }); + }); + + /** + * The events parameter in `sortedChanges()` filters out events by change + * type. This test seeds two "people" and creates two observables to test + * the filtering. The first observable filters to 'added' and the second + * filters to 'modified'. + */ + it('should filter by event type', (done: jest.DoneCallback) => { + const { colRef, davidDoc, expectedEvents } = seedTest(firestore); + + const addedChanges = sortedChanges(colRef, { events: ['added'] }).pipe(unwrapChange); + const modifiedChanges = sortedChanges(colRef, { events: ['modified'] }).pipe( + unwrapChange, + ); + + addedChanges.subscribe((data) => { + // kick off the modifiedChanges observable + expect(data).toEqual(expectedEvents); + davidDoc.update({ name: 'David!' }); + }); + + modifiedChanges.subscribe((data) => { + const expectedModifiedEvent = [{ name: 'David!', type: 'modified' }]; + expect(data).toEqual(expectedModifiedEvent); + done(); + }); + }); + }); + + describe('auditTrail', () => { + /** + * The `auditTrail()` method returns an array of every change that has + * occurred in the application. This test seeds two "people" into the + * collection and checks that the two added events are there. It then + * modifies a "person" and makes sure that event is on the array as well. + */ + it('should keep create a list of all changes', (done: jest.DoneCallback) => { + const { colRef, expectedEvents, davidDoc } = seedTest(firestore); + + const firstAudit = auditTrail(colRef).pipe(unwrapChange, take(1)); + const secondAudit = auditTrail(colRef).pipe(unwrapChange, skip(1)); + + firstAudit.subscribe((list) => { + expect(list).toEqual(expectedEvents); + davidDoc.update({ name: 'David!' }); + }); + + secondAudit.subscribe((list) => { + const modifiedList = [ + ...expectedEvents, + { name: 'David!', type: 'modified' }, + ]; + expect(list).toEqual(modifiedList); + done(); + }); + }); + + /** + * This test seeds two "people" into the collection. It then creates an + * auditList observable that is filtered to 'modified' events. It modifies + * a "person" document and ensures that list contains only the 'modified' + * event. + * //TODO: Works when running test directly (jest run file and skip all other tests in this file) BUT fails otherwise + */ + it('should filter the trail of events by event type', (done: jest.DoneCallback) => { + const { colRef, davidDoc } = seedTest(firestore); + + const modifiedAudit = auditTrail(colRef, { events: ['modified'] }).pipe(unwrapChange); + + modifiedAudit.subscribe((updateList) => { + const expectedEvents = [{ type: 'modified', name: 'David!' }]; + expect(updateList).toEqual(expectedEvents); + done(); + }); + + //use set timeout to wait for david to be written + setTimeout(() => davidDoc.update({ name: 'David!' }), 50); + }); + }); + + describe('auditTrail', () => { + /** + * The `auditTrail()` method returns an array of every change that has + * occurred in the application. This test seeds two "people" into the + * collection and checks that the two added events are there. It then + * modifies a "person" and makes sure that event is on the array as well. + */ + it('should keep create a list of all changes', (done: jest.DoneCallback) => { + const { colRef, expectedEvents, davidDoc } = seedTest(firestore); + + const firstAudit = auditTrail(colRef).pipe(unwrapChange, take(1)); + const secondAudit = auditTrail(colRef).pipe(unwrapChange, skip(1)); + + firstAudit.subscribe((list) => { + expect(list).toEqual(expectedEvents); + davidDoc.update({ name: 'David!' }); + }); + + secondAudit.subscribe((list) => { + const modifiedList = [ + ...expectedEvents, + { name: 'David!', type: 'modified' }, + ]; + expect(list).toEqual(modifiedList); + done(); + }); + }); + + /** + * This test seeds two "people" into the collection. The wrap operator then converts + * //TODO: Works when running test directly (jest run file and skip all other tests in this file) BUT fails otherwise + */ + it('should filter the trail of events by event type', (done: jest.DoneCallback) => { + const { colRef, davidDoc } = seedTest(firestore); + + const modifiedAudit = auditTrail(colRef, { events: ['modified'] }).pipe(unwrapChange); + + modifiedAudit.subscribe((updateList) => { + const expectedEvents = [{ type: 'modified', name: 'David!' }]; + expect(updateList).toEqual(expectedEvents); + done(); + }); + + //use set timeout to wait for david to be written + setTimeout(() => davidDoc.update({ name: 'David!' }), 50); + }); + }); + + describe('Data Mapping Functions', () => { + /** + * The `unwrap(id)` method will map a collection to its data payload and map the doc ID to a the specificed key. + */ + it('collectionData should map a QueryDocumentSnapshot[] to an array of plain objects', (done: jest.DoneCallback) => { + const { colRef } = seedTest(firestore); + + // const unwrapped = collection(colRef).pipe(unwrap('userId')); + const unwrapped = collectionData(colRef, { idField: 'userId' }); + + unwrapped.subscribe((val) => { + const expectedDoc = { + name: 'David', + userId: 'david', + }; + expect(val).toBeInstanceOf(Array); + expect(val[0]).toEqual(expectedDoc); + done(); + }); + }); + + it('docData should map a QueryDocumentSnapshot to a plain object', (done: jest.DoneCallback) => { + const { davidDoc } = seedTest(firestore); + + // const unwrapped = doc(davidDoc).pipe(unwrap('UID')); + const unwrapped = docData(davidDoc, { idField: 'UID' }); + + unwrapped.subscribe((val) => { + const expectedDoc = { + name: 'David', + UID: 'david', + }; + expect(val).toEqual(expectedDoc); + done(); + }); + }); + + it('docData should be able to provide SnapshotOptions', (done: jest.DoneCallback) => { + const { davidDoc } = seedTest(firestore); + const unwrapped = docData(davidDoc, { idField: 'UID' }); + + unwrapped.subscribe((val) => { + const expectedDoc = { + name: 'David', + UID: 'david', + }; + expect(val).toEqual(expectedDoc); + done(); + }); + }); + + /** + * TODO(jamesdaniels) + * Having trouble gettings these test green with the emulators + * FIRESTORE (8.5.0) INTERNAL ASSERTION FAILED: Unexpected state + */ + + it('docData matches the result of docSnapShot.data() when the document doesn\'t exist', (done) => { + // pending('Not working against the emulator'); + + const { colRef } = seedTest(firestore); + + const nonExistentDoc: DocumentReference = colRef.doc( + createId(), + ); + + const unwrapped = docData(nonExistentDoc); + + nonExistentDoc.get().then((snap) => { + unwrapped.subscribe((val) => { + expect(val).toEqual(snap.data()); + done(); + }); + }); + }); + + it('collectionData matches the result of querySnapShot.docs when the collection doesn\'t exist', (done) => { + // pending('Not working against the emulator'); + + const nonExistentCollection = firestore.collection(createId()); + + const unwrapped = collectionData(nonExistentCollection); + + nonExistentCollection.get().then((snap) => { + unwrapped.subscribe((val) => { + expect(val).toEqual(snap.docs); + done(); + }); + }); + }); + }); + + describe('Aggregations', () => { + it('should provide an observable with a count aggregate snapshot', async () => { + const colRef = createRandomCol(firestore); + const entries = [ + colRef.add({ id: createId() }), + colRef.add({ id: createId() }), + ]; + await Promise.all(entries); + + collectionCountSnap(colRef).subscribe((snap) => { + expect(snap.data().count).toEqual(entries.length); + }); + }); + + it('should provide an observable with a count aggregate number', async () => { + const colRef = createRandomCol(firestore); + const entries = [ + colRef.add({ id: createId() }), + colRef.add({ id: createId() }), + colRef.add({ id: createId() }), + colRef.add({ id: createId() }), + colRef.add({ id: createId() }), + ]; + await Promise.all(entries); + + collectionCount(colRef).subscribe((count) => { + expect(count).toEqual(entries.length); + }); + }); + }); +});