UNPKG

firestore-jest-mock

Version:

Jest helper for mocking Google Cloud Firestore

479 lines 17.5 kB
"use strict"; const mockCollectionGroup = jest.fn(); const mockBatch = jest.fn(); const mockRunTransaction = jest.fn(); const mockRecursiveDelete = jest.fn(); const mockSettings = jest.fn(); const mockUseEmulator = jest.fn(); const mockCollection = jest.fn(); const mockDoc = jest.fn(); const mockCreate = jest.fn(); const mockUpdate = jest.fn(); const mockSet = jest.fn(); const mockAdd = jest.fn(); const mockDelete = jest.fn(); const mockListDocuments = jest.fn(); const mockListCollections = jest.fn(); const mockBatchDelete = jest.fn(); const mockBatchCommit = jest.fn(); const mockBatchUpdate = jest.fn(); const mockBatchSet = jest.fn(); const mockBatchCreate = jest.fn(); const mockOnSnapShot = jest.fn(); const timestamp = require('./timestamp'); const fieldValue = require('./fieldValue'); const query = require('./query'); const transaction = require('./transaction'); const path = require('./path'); const buildDocFromHash = require('./helpers/buildDocFromHash'); const buildQuerySnapShot = require('./helpers/buildQuerySnapShot'); const _randomId = () => Math.floor(Math.random() * Number.MAX_SAFE_INTEGER).toString(); class FakeFirestore { constructor(stubbedDatabase = {}, options = {}) { this.database = timestamp.convertTimestamps(stubbedDatabase); this.query = new query.Query('', this); this.options = options; } set collectionName(collectionName) { this.query.collectionName = collectionName; this.recordToFetch = null; } get collectionName() { return this.query.collectionName; } getAll(...params) { //Strip ReadOptions object params = params.filter(arg => arg instanceof FakeFirestore.DocumentReference); return Promise.all(transaction.mocks.mockGetAll(...params) || [...params].map(r => r.get())); } batch() { mockBatch(...arguments); return { _ref: this, delete() { mockBatchDelete(...arguments); return this; }, set(doc, data, setOptions = {}) { mockBatchSet(...arguments); this._ref._updateData(doc.path, data, setOptions.merge); return this; }, update(doc, data) { mockBatchUpdate(...arguments); this._ref._updateData(doc.path, data, true); return this; }, create(doc, data) { mockBatchCreate(...arguments); this._ref._updateData(doc.path, data, false); return this; }, commit() { mockBatchCommit(...arguments); return Promise.resolve([]); }, }; } settings() { mockSettings(...arguments); return; } useEmulator() { mockUseEmulator(...arguments); } collection(path) { // Accept any collection path // See https://firebase.google.com/docs/reference/js/firestore_#collection mockCollection(...arguments); if (path === undefined) { throw new Error(`FakeFirebaseError: Function Firestore.collection() requires 1 argument, but was called with 0 arguments.`); } else if (!path || typeof path !== 'string') { throw new Error(`FakeFirebaseError: Function Firestore.collection() requires its first argument to be of type non-empty string, but it was: ${JSON.stringify(path)}`); } // Ignore leading slash const pathArray = path.replace(/^\/+/, '').split('/'); // Must be collection-level, so odd-numbered elements if (pathArray.length % 2 !== 1) { throw new Error(`FakeFirebaseError: Invalid collection reference. Collection references must have an odd number of segments, but ${path} has ${pathArray.length}`); } const { coll } = this._docAndColForPathArray(pathArray); return coll; } collectionGroup(collectionId) { mockCollectionGroup(...arguments); return new FakeFirestore.Query(collectionId, this, true); } doc(path) { mockDoc(path); return this._doc(path); } _doc(path) { // Accept any document path // See https://firebase.google.com/docs/reference/js/firestore_#doc if (path === undefined) { throw new Error(`FakeFirebaseError: Function Firestore.doc() requires 1 argument, but was called with 0 arguments.`); } else if (!path || typeof path !== 'string') { throw new Error(`FakeFirebaseError: Function Firestore.doc() requires its first argument to be of type non-empty string, but it was: ${JSON.stringify(path)}`); } // Ignore leading slash const pathArray = path.replace(/^\/+/, '').split('/'); // Must be document-level, so even-numbered elements if (pathArray.length % 2 !== 0) { throw new Error(`FakeFirebaseError: Invalid document reference. Document references must have an even number of segments, but ${path} has ${pathArray.length} `); } const { doc } = this._docAndColForPathArray(pathArray); return doc; } _docAndColForPathArray(pathArray) { let doc = null; let coll = null; for (let index = 0; index < pathArray.length; index += 2) { const collectionId = pathArray[index] || ''; const documentId = pathArray[index + 1] || ''; coll = new FakeFirestore.CollectionReference(collectionId, doc, this); if (!documentId) { break; } doc = new FakeFirestore.DocumentReference(documentId, coll); } return { doc, coll }; } runTransaction(updateFunction) { mockRunTransaction(...arguments); return updateFunction(new FakeFirestore.Transaction()); } _updateData(path, object, merge) { // Do not update unless explicity set to mutable. if (!this.options.mutable) { return; } // note: this logic could be deduplicated const pathArray = path.replace(/^\/+/, '').split('/'); // Must be document-level, so even-numbered elements if (pathArray.length % 2 !== 0) { throw new Error(`FakeFirebaseError: Invalid document reference. Document references must have an even number of segments, but ${path} has ${pathArray.length}`); } // The parent entry is the id of the document const docId = pathArray.pop(); // Find the parent of docId. Run through the path, creating missing entries const parent = pathArray.reduce((last, entry, index) => { const isCollection = index % 2 === 0; if (isCollection) { return last[entry] || (last[entry] = []); } else { const existingDoc = last.find(doc => doc.id === entry); if (existingDoc) { // return _collections, creating it if it doesn't already exist return existingDoc._collections || (existingDoc._collections = {}); } const _collections = {}; last.push({ id: entry, _collections }); return _collections; } }, this.database); // parent should now be an array of documents // Replace existing data, if it's there, or add to the end of the array const oldIndex = parent.findIndex(doc => doc.id === docId); parent[oldIndex >= 0 ? oldIndex : parent.length] = { ...(merge ? parent[oldIndex] : undefined), ...object, id: docId, }; } recursiveDelete(ref, bulkWriter) { mockRecursiveDelete(...arguments); return Promise.resolve(); } } FakeFirestore.Query = query.Query; FakeFirestore.FieldValue = fieldValue.FieldValue; FakeFirestore.Timestamp = timestamp.Timestamp; FakeFirestore.Transaction = transaction.Transaction; FakeFirestore.FieldPath = path.FieldPath; /* * ============ * Document Reference * ============ */ FakeFirestore.DocumentReference = class { constructor(id, parent) { this.id = id; this.parent = parent; this.firestore = parent.firestore; this.path = parent.path.split('/').concat(id).join('/'); } collection(collectionName) { mockCollection(...arguments); return new FakeFirestore.CollectionReference(collectionName, this); } listCollections() { mockListCollections(); const document = this._getRawObject(); if (!document._collections) { return Promise.resolve([]); } const collectionRefs = []; for (const collectionId of Object.keys(document._collections)) { collectionRefs.push(new FakeFirestore.CollectionReference(collectionId, this)); } return Promise.resolve(collectionRefs); } delete() { mockDelete(...arguments); return Promise.resolve(); } onSnapshot() { mockOnSnapShot(...arguments); let callback; let errorCallback; // eslint-disable-next-line let options; try { if (typeof arguments[0] === 'function') { [callback, errorCallback] = arguments; } else { // eslint-disable-next-line no-unused-vars [options, callback, errorCallback] = arguments; } callback(this._get()); } catch (e) { if (errorCallback) { errorCallback(e); } else { throw e; } } // Returns an unsubscribe function return () => { }; } get() { query.mocks.mockGet(...arguments); const data = this._get(); return Promise.resolve(data); } create(object) { mockCreate(...arguments); this.firestore._updateData(this.path, object, false); return Promise.resolve(buildDocFromHash({ ...object, _ref: this, _updateTime: timestamp.Timestamp.now() })); } update(object) { mockUpdate(...arguments); if (this._get().exists) { this.firestore._updateData(this.path, object, true); } return Promise.resolve(buildDocFromHash({ ...object, _ref: this, _updateTime: timestamp.Timestamp.now() })); } set(object, setOptions = {}) { mockSet(...arguments); this.firestore._updateData(this.path, object, setOptions.merge); return Promise.resolve(buildDocFromHash({ ...object, _ref: this, _updateTime: timestamp.Timestamp.now() })); } isEqual(other) { return (other instanceof FakeFirestore.DocumentReference && other.firestore === this.firestore && other.path === this.path); } orderBy() { return this.query.orderBy(...arguments); } limit() { return this.query.limit(...arguments); } offset() { return this.query.offset(...arguments); } startAfter() { return this.query.startAfter(...arguments); } startAt() { return this.query.startAt(...arguments); } /** * A private method for internal use. * @returns {Object|null} The raw object of the document or null. */ _getRawObject() { // Ignore leading slash const pathArray = this.path.replace(/^\/+/, '').split('/'); if (pathArray[0] === 'database') { pathArray.shift(); // drop 'database'; it was included in legacy paths, but we don't need it now } let requestedRecords = this.firestore.database[pathArray.shift()]; let document = null; if (requestedRecords) { const documentId = pathArray.shift(); document = requestedRecords.find(record => record.id === documentId); } else { return null; } for (let index = 0; index < pathArray.length; index += 2) { const collectionId = pathArray[index]; const documentId = pathArray[index + 1]; if (!document || !document._collections) { return null; } requestedRecords = document._collections[collectionId] || []; if (requestedRecords.length === 0) { return null; } document = requestedRecords.find(record => record.id === documentId); if (!document) { return null; } // +2 skips to next document } if (!!document || false) { return document; } return null; } _get() { const document = this._getRawObject(); if (document) { document._ref = this; document._readTime = timestamp.Timestamp.now(); return buildDocFromHash(document); } else { return { createTime: undefined, exists: false, data: () => undefined, id: this.id, readTime: undefined, ref: this, updateTime: undefined, }; } } withConverter() { query.mocks.mockWithConverter(...arguments); return this; } }; /* * ============ * Collection Reference * ============ */ FakeFirestore.CollectionReference = class extends FakeFirestore.Query { constructor(id, parent, firestore) { super(id, firestore || parent.firestore); this.id = id; this.parent = parent; if (parent) { this.path = parent.path.concat(`/${id}`); } else { this.path = id; } } add(object) { mockAdd(...arguments); const newDoc = new FakeFirestore.DocumentReference(_randomId(), this); this.firestore._updateData(newDoc.path, object); return Promise.resolve(newDoc); } doc(id = _randomId()) { mockDoc(id); return new FakeFirestore.DocumentReference(id, this, this.firestore); } /** * A private method, meant mainly to be used by `get` and other internal objects to retrieve * the list of database records referenced by this CollectionReference. * @returns {Object[]} An array of mocked document records. */ _records() { // Support subcollections as paths: "collection/documentId/subcollection" if (this.firestore.database[this.path]) { return this.firestore.database[this.path]; } // Ignore leading slash const pathArray = this.path.replace(/^\/+/, '').split('/'); let requestedRecords = this.firestore.database[pathArray.shift()]; if (pathArray.length === 0) { return requestedRecords || []; } // Since we're a collection, we can assume that pathArray.length % 2 is always 0 for (let index = 0; index < pathArray.length; index += 2) { const documentId = pathArray[index]; const collectionId = pathArray[index + 1]; if (!requestedRecords) { return []; } const document = requestedRecords.find(record => record.id === documentId); if (!document || !document._collections) { return []; } requestedRecords = document._collections[collectionId] || []; if (requestedRecords.length === 0) { return []; } // +2 skips to next collection } return requestedRecords; } listDocuments() { mockListDocuments(); // Returns all documents, including documents with no data but with // subcollections: see https://googleapis.dev/nodejs/firestore/latest/CollectionReference.html#listDocuments return Promise.resolve(this._records().map(rec => new FakeFirestore.DocumentReference(rec.id, this, this.firestore))); } get() { query.mocks.mockGet(...arguments); return Promise.resolve(this._get()); } _get() { // Make sure we have a 'good enough' document reference const records = this._records().map(rec => ({ ...rec, _ref: new FakeFirestore.DocumentReference(rec.id, this, this.firestore), })); // Firestore does not return documents with no local data const isFilteringEnabled = this.firestore.options.simulateQueryFilters; return buildQuerySnapShot(records, isFilteringEnabled ? this.filters : undefined, this.selectFields); } isEqual(other) { return (other instanceof FakeFirestore.CollectionReference && other.firestore === this.firestore && other.path === this.path); } }; module.exports = { FakeFirestore, mockBatch, mockRunTransaction, mockRecursiveDelete, mockCollection, mockCollectionGroup, mockDoc, mockAdd, mockDelete, mockCreate, mockUpdate, mockSet, mockSettings, mockUseEmulator, mockBatchDelete, mockBatchCommit, mockBatchUpdate, mockBatchSet, mockBatchCreate, mockOnSnapShot, mockListDocuments, mockListCollections, ...query.mocks, ...transaction.mocks, ...fieldValue.mocks, ...timestamp.mocks, }; //# sourceMappingURL=firestore.js.map