firestore-jest-mock
Advanced tools
Comparing version 0.13.0 to 0.14.0
@@ -112,2 +112,3 @@ const { mockGoogleCloudFirestore } = require('firestore-jest-mock'); | ||
test('collectionGroup with subcollections', () => { | ||
jest.clearAllMocks(); | ||
const firestore = new this.Firestore(); | ||
@@ -117,8 +118,8 @@ | ||
.collectionGroup('cities') | ||
.where('type', '==', 'museum') | ||
.where('country', '==', 'USA') | ||
.get() | ||
.then(querySnapshot => { | ||
expect(mockCollectionGroup).toHaveBeenCalledWith('cities'); | ||
expect(mockGet).toHaveBeenCalled(); | ||
expect(mockWhere).toHaveBeenCalledWith('type', '==', 'museum'); | ||
expect(mockGet).toHaveBeenCalledTimes(1); | ||
expect(mockWhere).toHaveBeenCalledWith('country', '==', 'USA'); | ||
@@ -245,5 +246,5 @@ expect(querySnapshot.forEach).toBeTruthy(); | ||
.onSnapshot(querySnapshot => { | ||
expect(querySnapshot).toHaveProperty('forEach'); | ||
expect(querySnapshot).toHaveProperty('forEach', expect.any(Function)); | ||
expect(querySnapshot).toHaveProperty('docChanges'); | ||
expect(querySnapshot).toHaveProperty('docs'); | ||
expect(querySnapshot).toHaveProperty('docs', expect.any(Array)); | ||
@@ -250,0 +251,0 @@ expect(querySnapshot.forEach).toBeInstanceOf(Function); |
@@ -1,52 +0,70 @@ | ||
const { mockFirebase } = require('firestore-jest-mock'); | ||
const { mockInitializeApp } = require('../mocks/firebase'); | ||
describe.each` | ||
filters | ||
${true} | ||
${false} | ||
`('we can start a firebase application (query filters: $filters)', ({ filters }) => { | ||
// We call `require` inside of a parameterized `describe` so we get | ||
// a fresh mocked Firebase to test cases with query filters turned on and off | ||
const flushPromises = () => new Promise(setImmediate); | ||
jest.resetModules(); | ||
const { mockFirebase } = require('firestore-jest-mock'); | ||
const { mockInitializeApp } = require('../mocks/firebase'); | ||
const { | ||
mockGet, | ||
mockAdd, | ||
mockSet, | ||
mockUpdate, | ||
mockWhere, | ||
mockCollectionGroup, | ||
mockBatch, | ||
mockBatchCommit, | ||
mockBatchDelete, | ||
mockBatchUpdate, | ||
mockBatchSet, | ||
mockSettings, | ||
mockOnSnapShot, | ||
mockUseEmulator, | ||
mockDoc, | ||
mockCollection, | ||
mockWithConverter, | ||
FakeFirestore, | ||
} = require('../mocks/firestore'); | ||
const flushPromises = () => new Promise(setImmediate); | ||
describe('we can start a firebase application', () => { | ||
mockFirebase({ | ||
database: { | ||
users: [ | ||
{ id: 'abc123', first: 'Bob', last: 'builder', born: 1998 }, | ||
{ | ||
id: '123abc', | ||
first: 'Blues', | ||
last: 'builder', | ||
born: 1996, | ||
_collections: { | ||
cities: [{ id: 'LA', name: 'Los Angeles', state: 'CA', country: 'USA', visited: true }], | ||
const { | ||
mockGet, | ||
mockAdd, | ||
mockSet, | ||
mockUpdate, | ||
mockWhere, | ||
mockCollectionGroup, | ||
mockBatch, | ||
mockBatchCommit, | ||
mockBatchDelete, | ||
mockBatchUpdate, | ||
mockBatchSet, | ||
mockSettings, | ||
mockOnSnapShot, | ||
mockUseEmulator, | ||
mockDoc, | ||
mockCollection, | ||
mockWithConverter, | ||
FakeFirestore, | ||
mockQueryOnSnapshot, | ||
} = require('../mocks/firestore'); | ||
mockFirebase( | ||
{ | ||
database: { | ||
users: [ | ||
{ id: 'abc123', first: 'Bob', last: 'builder', born: 1998 }, | ||
{ | ||
id: '123abc', | ||
first: 'Blues', | ||
last: 'builder', | ||
born: 1996, | ||
_collections: { | ||
cities: [ | ||
{ id: 'LA', name: 'Los Angeles', state: 'CA', country: 'USA', visited: true }, | ||
{ id: 'Mex', name: 'Mexico City', country: 'Mexico', visited: true }, | ||
], | ||
}, | ||
}, | ||
}, | ||
], | ||
cities: [ | ||
{ id: 'LA', name: 'Los Angeles', state: 'CA', country: 'USA' }, | ||
{ id: 'DC', name: 'Disctric of Columbia', state: 'DC', country: 'USA' }, | ||
], | ||
], | ||
cities: [ | ||
{ id: 'LA', name: 'Los Angeles', state: 'CA', country: 'USA' }, | ||
{ id: 'DC', name: 'Disctric of Columbia', state: 'DC', country: 'USA' }, | ||
], | ||
}, | ||
}, | ||
}); | ||
{ simulateQueryFilters: filters }, | ||
); | ||
/** @type {import('firebase').default} */ | ||
const firebase = require('firebase'); | ||
beforeEach(() => { | ||
this.firebase = require('firebase'); | ||
this.firebase.initializeApp({ | ||
jest.resetAllMocks(); | ||
firebase.initializeApp({ | ||
apiKey: '### FIREBASE API KEY ###', | ||
@@ -59,3 +77,3 @@ authDomain: '### FIREBASE AUTH DOMAIN ###', | ||
test('We can start an application', async () => { | ||
const db = this.firebase.firestore(); | ||
const db = firebase.firestore(); | ||
db.settings({ ignoreUndefinedProperties: true }); | ||
@@ -67,3 +85,3 @@ expect(mockInitializeApp).toHaveBeenCalled(); | ||
test('we can use emulator', async () => { | ||
const db = this.firebase.firestore(); | ||
const db = firebase.firestore(); | ||
db.useEmulator('localhost', 9000); | ||
@@ -75,3 +93,3 @@ expect(mockUseEmulator).toHaveBeenCalledWith('localhost', 9000); | ||
test('add a user', () => { | ||
const db = this.firebase.firestore(); | ||
const db = firebase.firestore(); | ||
@@ -91,2 +109,3 @@ // Example from documentation: | ||
expect(docRef).toHaveProperty('id'); | ||
expect(docRef).toHaveProperty('path', `users/${docRef.id}`); | ||
}); | ||
@@ -96,3 +115,3 @@ }); | ||
test('get all users', () => { | ||
const db = this.firebase.firestore(); | ||
const db = firebase.firestore(); | ||
// Example from documentation: | ||
@@ -109,2 +128,5 @@ // https://firebase.google.com/docs/firestore/quickstart#read_data | ||
const paths = querySnapshot.docs.map(d => d.ref.path).sort(); | ||
const expectedPaths = ['users/abc123', 'users/123abc'].sort(); | ||
expect(paths).toStrictEqual(expectedPaths); | ||
querySnapshot.forEach(doc => { | ||
@@ -118,7 +140,6 @@ expect(doc.exists).toBe(true); | ||
test('collectionGroup at root', () => { | ||
const db = this.firebase.firestore(); | ||
test('collectionGroup with collections only at root', () => { | ||
const db = firebase.firestore(); | ||
// Example from documentation: | ||
// https://firebase.google.com/docs/firestore/query-data/queries#collection-group-query | ||
return db | ||
@@ -130,3 +151,3 @@ .collectionGroup('users') | ||
expect(mockCollectionGroup).toHaveBeenCalledWith('users'); | ||
expect(mockGet).toHaveBeenCalled(); | ||
expect(mockGet).toHaveBeenCalledTimes(1); | ||
expect(mockWhere).toHaveBeenCalledWith('last', '==', 'builder'); | ||
@@ -145,15 +166,14 @@ | ||
test('collectionGroup with subcollections', () => | ||
this.firebase | ||
.firestore() | ||
test('collectionGroup with subcollections', () => { | ||
const db = firebase.firestore(); | ||
return db | ||
.collectionGroup('cities') | ||
.where('type', '==', 'museum') | ||
.get() | ||
.then(querySnapshot => { | ||
expect(mockCollectionGroup).toHaveBeenCalledWith('cities'); | ||
expect(mockGet).toHaveBeenCalled(); | ||
expect(mockWhere).toHaveBeenCalledWith('type', '==', 'museum'); | ||
expect(mockGet).toHaveBeenCalledTimes(1); | ||
expect(mockWhere).not.toHaveBeenCalled(); | ||
expect(querySnapshot.forEach).toBeTruthy(); | ||
expect(querySnapshot.docs.length).toBe(3); | ||
expect(querySnapshot.docs.length).toBe(4); | ||
expect(querySnapshot.size).toBe(querySnapshot.docs.length); | ||
@@ -165,6 +185,29 @@ | ||
}); | ||
})); | ||
}); | ||
}); | ||
test('collectionGroup with queried subcollections', () => { | ||
const db = firebase.firestore(); | ||
return db | ||
.collectionGroup('cities') | ||
.where('country', '==', 'USA') | ||
.get() | ||
.then(querySnapshot => { | ||
expect(mockCollectionGroup).toHaveBeenCalledWith('cities'); | ||
expect(mockGet).toHaveBeenCalledTimes(1); | ||
expect(mockWhere).toHaveBeenCalledWith('country', '==', 'USA'); | ||
expect(querySnapshot.forEach).toBeTruthy(); | ||
expect(querySnapshot.docs.length).toBe(filters ? 3 : 4); | ||
expect(querySnapshot.size).toBe(querySnapshot.docs.length); | ||
querySnapshot.forEach(doc => { | ||
expect(doc.exists).toBe(true); | ||
expect(doc.data()).toBeTruthy(); | ||
}); | ||
}); | ||
}); | ||
test('set a city', () => { | ||
const db = this.firebase.firestore(); | ||
const db = firebase.firestore(); | ||
// Example from documentation: | ||
@@ -191,3 +234,3 @@ // https://firebase.google.com/docs/firestore/manage-data/add-data#set_a_document\ | ||
test('updating a city', () => { | ||
const db = this.firebase.firestore(); | ||
const db = firebase.firestore(); | ||
// Example from documentation: | ||
@@ -208,3 +251,3 @@ // https://firebase.google.com/docs/firestore/manage-data/add-data#update-data | ||
test('batch writes', () => { | ||
const db = this.firebase.firestore(); | ||
const db = firebase.firestore(); | ||
// Example from documentation: | ||
@@ -240,3 +283,3 @@ // https://cloud.google.com/firestore/docs/manage-data/transactions | ||
test('onSnapshot single doc', async () => { | ||
const db = this.firebase.firestore(); | ||
const db = firebase.firestore(); | ||
@@ -257,6 +300,7 @@ // Example from documentation: | ||
expect(mockOnSnapShot).toHaveBeenCalled(); | ||
expect(mockQueryOnSnapshot).not.toHaveBeenCalled(); | ||
}); | ||
test('onSnapshot can work with options', async () => { | ||
const db = this.firebase.firestore(); | ||
const db = firebase.firestore(); | ||
@@ -283,6 +327,7 @@ // Example from documentation: | ||
expect(mockOnSnapShot).toHaveBeenCalled(); | ||
expect(mockQueryOnSnapshot).not.toHaveBeenCalled(); | ||
}); | ||
test('onSnapshot with query', async () => { | ||
const db = this.firebase.firestore(); | ||
const db = firebase.firestore(); | ||
@@ -296,5 +341,5 @@ // Example from documentation: | ||
.onSnapshot(querySnapshot => { | ||
expect(querySnapshot).toHaveProperty('forEach'); | ||
expect(querySnapshot).toHaveProperty('forEach', expect.any(Function)); | ||
expect(querySnapshot).toHaveProperty('docChanges'); | ||
expect(querySnapshot).toHaveProperty('docs'); | ||
expect(querySnapshot).toHaveProperty('docs', expect.any(Array)); | ||
@@ -312,3 +357,4 @@ expect(querySnapshot.forEach).toBeInstanceOf(Function); | ||
expect(mockWhere).toHaveBeenCalled(); | ||
expect(mockOnSnapShot).toHaveBeenCalled(); | ||
expect(mockOnSnapShot).not.toHaveBeenCalled(); | ||
expect(mockQueryOnSnapshot).toHaveBeenCalled(); | ||
}); | ||
@@ -323,3 +369,3 @@ | ||
test('single document', async () => { | ||
const db = this.firebase.firestore(); | ||
const db = firebase.firestore(); | ||
@@ -339,3 +385,3 @@ const recordDoc = db.doc('cities/la').withConverter(converter); | ||
test('single undefined document', async () => { | ||
const db = this.firebase.firestore(); | ||
const db = firebase.firestore(); | ||
@@ -349,3 +395,3 @@ const recordDoc = db | ||
expect(mockWithConverter).toHaveBeenCalledWith(converter); | ||
expect(mockDoc).toHaveBeenCalledWith('abc123'); | ||
expect(mockDoc).toHaveBeenCalled(); | ||
expect(recordDoc).toBeInstanceOf(FakeFirestore.DocumentReference); | ||
@@ -360,3 +406,3 @@ | ||
test('multiple documents', async () => { | ||
const db = this.firebase.firestore(); | ||
const db = firebase.firestore(); | ||
@@ -363,0 +409,0 @@ const recordsCol = db.collection('cities').withConverter(converter); |
@@ -10,26 +10,30 @@ const { FakeFirestore } = require('firestore-jest-mock'); | ||
const db = new FakeFirestore({ | ||
characters: [ | ||
const db = (simulateQueryFilters = false) => | ||
new FakeFirestore( | ||
{ | ||
id: 'homer', | ||
name: 'Homer', | ||
occupation: 'technician', | ||
address: { street: '742 Evergreen Terrace' }, | ||
characters: [ | ||
{ | ||
id: 'homer', | ||
name: 'Homer', | ||
occupation: 'technician', | ||
address: { street: '742 Evergreen Terrace' }, | ||
}, | ||
{ id: 'krusty', name: 'Krusty', occupation: 'clown' }, | ||
{ | ||
id: 'bob', | ||
name: 'Bob', | ||
occupation: 'insurance agent', | ||
_collections: { | ||
family: [ | ||
{ id: 'violet', name: 'Violet', relation: 'daughter' }, | ||
{ id: 'dash', name: 'Dash', relation: 'son' }, | ||
{ id: 'jackjack', name: 'Jackjack', relation: 'son' }, | ||
{ id: 'helen', name: 'Helen', relation: 'wife' }, | ||
], | ||
}, | ||
}, | ||
], | ||
}, | ||
{ id: 'krusty', name: 'Krusty', occupation: 'clown' }, | ||
{ | ||
id: 'bob', | ||
name: 'Bob', | ||
occupation: 'insurance agent', | ||
_collections: { | ||
family: [ | ||
{ id: 'violet', name: 'Violet', relation: 'daughter' }, | ||
{ id: 'dash', name: 'Dash', relation: 'son' }, | ||
{ id: 'jackjack', name: 'Jackjack', relation: 'son' }, | ||
{ id: 'helen', name: 'Helen', relation: 'wife' }, | ||
], | ||
}, | ||
}, | ||
], | ||
}); | ||
{ simulateQueryFilters }, | ||
); | ||
@@ -39,3 +43,3 @@ describe('Single records versus queries', () => { | ||
expect.assertions(6); | ||
const record = await db | ||
const record = await db() | ||
.collection('characters') | ||
@@ -55,3 +59,3 @@ .doc('krusty') | ||
expect.assertions(4); | ||
const record = await db | ||
const record = await db() | ||
.collection('animals') | ||
@@ -67,3 +71,3 @@ .doc('monkey') | ||
test('it can fetch a single record with a promise', () => | ||
db | ||
db() | ||
.collection('characters') | ||
@@ -88,3 +92,3 @@ .doc('homer') | ||
test('it can fetch a single record with a promise without a specified collection', () => | ||
db | ||
db() | ||
.doc('characters/homer') | ||
@@ -104,3 +108,3 @@ .get() | ||
test('it can fetch multiple records and returns documents', async () => { | ||
const records = await db | ||
const records = await db() | ||
.collection('characters') | ||
@@ -120,31 +124,54 @@ .where('name', '==', 'Homer') | ||
test('it throws an error if the collection path ends at a document', () => { | ||
expect(() => db().collection('')).toThrow(Error); | ||
expect(db().collection('foo')).toBeInstanceOf(FakeFirestore.CollectionReference); | ||
expect(() => db().collection('foo/bar')).toThrow(Error); | ||
expect(db().collection('foo/bar/baz')).toBeInstanceOf(FakeFirestore.CollectionReference); | ||
}); | ||
test('it throws an error if the document path ends at a collection', () => { | ||
expect(() => db.doc('characters')).toThrow(Error); | ||
expect(() => db.doc('characters/bob')).not.toThrow(); | ||
expect(() => db.doc('characters/bob/family')).toThrow(Error); | ||
expect(() => db().doc('')).toThrow(Error); | ||
expect(() => db().doc('characters')).toThrow(Error); | ||
expect(db().doc('characters/bob')).toBeInstanceOf(FakeFirestore.DocumentReference); | ||
expect(() => db().doc('characters/bob/family')).toThrow(Error); | ||
}); | ||
test('it can fetch nonexistent documents from a root collection', async () => { | ||
expect.assertions(2); | ||
const nope = await db.doc('characters/joe').get(); | ||
expect(nope.exists).toBe(false); | ||
expect(nope.id).toBe('joe'); | ||
const nope = await db() | ||
.doc('characters/joe') | ||
.get(); | ||
expect(nope).toHaveProperty('exists', false); | ||
expect(nope).toHaveProperty('id', 'joe'); | ||
expect(nope).toHaveProperty('ref'); | ||
expect(nope.ref).toHaveProperty('path', 'characters/joe'); | ||
}); | ||
test('it can fetch nonexistent documents from extant subcollections', async () => { | ||
const nope = await db.doc('characters/bob/family/thing3').get(); | ||
expect(nope.exists).toBe(false); | ||
expect(nope.id).toBe('thing3'); | ||
const nope = await db() | ||
.doc('characters/bob/family/thing3') | ||
.get(); | ||
expect(nope).toHaveProperty('exists', false); | ||
expect(nope).toHaveProperty('id', 'thing3'); | ||
expect(nope).toHaveProperty('ref'); | ||
expect(nope.ref).toHaveProperty('path', 'characters/bob/family/thing3'); | ||
}); | ||
test('it can fetch nonexistent documents from nonexistent subcollections', async () => { | ||
const nope = await db.doc('characters/sam/family/phil').get(); | ||
expect(nope.exists).toBe(false); | ||
expect(nope.id).toBe('phil'); | ||
const nope = await db() | ||
.doc('characters/sam/family/phil') | ||
.get(); | ||
expect(nope).toHaveProperty('exists', false); | ||
expect(nope).toHaveProperty('id', 'phil'); | ||
expect(nope).toHaveProperty('ref'); | ||
expect(nope.ref).toHaveProperty('path', 'characters/sam/family/phil'); | ||
}); | ||
test('it can fetch nonexistent documents from nonexistent root collections', async () => { | ||
const nope = await db.doc('foo/bar/baz/bin').get(); | ||
expect(nope.exists).toBe(false); | ||
expect(nope.id).toBe('bin'); | ||
const nope = await db() | ||
.doc('foo/bar/baz/bin') | ||
.get(); | ||
expect(nope).toHaveProperty('exists', false); | ||
expect(nope).toHaveProperty('id', 'bin'); | ||
expect(nope).toHaveProperty('ref'); | ||
expect(nope.ref).toHaveProperty('path', 'foo/bar/baz/bin'); | ||
}); | ||
@@ -154,11 +181,15 @@ | ||
expect.assertions(1); | ||
const records = await db | ||
const records = await db() | ||
.collection('animals') | ||
.where('type', '==', 'mammal') | ||
.get(); | ||
expect(records.empty).toBe(true); | ||
expect(records).toHaveProperty('empty', true); | ||
}); | ||
test('it can fetch multiple records as a promise', () => | ||
db | ||
test.each` | ||
simulateQueryFilters | expectedSize | ||
${true} | ${1} | ||
${false} | ${3} | ||
`('it can fetch multiple records as a promise', ({ simulateQueryFilters, expectedSize }) => | ||
db(simulateQueryFilters) | ||
.collection('characters') | ||
@@ -168,15 +199,21 @@ .where('name', '==', 'Homer') | ||
.then(records => { | ||
expect(records.empty).toBe(false); | ||
expect(records).toHaveProperty('empty', false); | ||
expect(records).toHaveProperty('docs', expect.any(Array)); | ||
expect(records).toHaveProperty('size', expectedSize); | ||
expect(records.docs[0]).toHaveProperty('id', 'homer'); | ||
expect(records.docs[0]).toHaveProperty('exists', true); | ||
expect(records.docs[0].data()).toHaveProperty('name', 'Homer'); | ||
})); | ||
}), | ||
); | ||
test('it can return all root records', async () => { | ||
expect.assertions(4); | ||
const firstRecord = db.collection('characters').doc('homer'); | ||
const secondRecord = db.collection('characters').doc('krusty'); | ||
const firstRecord = db() | ||
.collection('characters') | ||
.doc('homer'); | ||
const secondRecord = db() | ||
.collection('characters') | ||
.doc('krusty'); | ||
const records = await db.getAll(firstRecord, secondRecord); | ||
const records = await db().getAll(firstRecord, secondRecord); | ||
expect(records.length).toBe(2); | ||
@@ -190,3 +227,3 @@ expect(records[0]).toHaveProperty('id', 'homer'); | ||
expect.assertions(4); | ||
const record = await db | ||
const record = await db() | ||
.collection('characters') | ||
@@ -202,8 +239,8 @@ .doc('bob') | ||
test('it can fetch records from subcollections', async () => { | ||
expect.assertions(7); | ||
const family = db | ||
expect.assertions(8); | ||
const family = db() | ||
.collection('characters') | ||
.doc('bob') | ||
.collection('family'); | ||
expect(family.path).toBe('database/characters/bob/family'); | ||
expect(family.path).toBe('characters/bob/family'); | ||
@@ -215,3 +252,3 @@ const allFamilyMembers = await family.get(); | ||
const ref = family.doc('violet'); | ||
expect(ref.path).toBe('database/characters/bob/family/violet'); | ||
expect(ref).toHaveProperty('path', 'characters/bob/family/violet'); | ||
@@ -221,16 +258,24 @@ const record = await ref.get(); | ||
expect(record).toHaveProperty('id', 'violet'); | ||
expect(record).toHaveProperty('data'); | ||
expect(record.data()).toHaveProperty('name', 'Violet'); | ||
}); | ||
test('it can fetch records from subcollections with query parameters', async () => { | ||
const family = db | ||
.collection('characters') | ||
.doc('bob') | ||
.collection('family') | ||
.where('relation', '==', 'son'); // should still return all | ||
expect(family.path).toBe('database/characters/bob/family'); | ||
test.each` | ||
simulateQueryFilters | expectedSize | ||
${true} | ${2} | ||
${false} | ${4} | ||
`( | ||
'it can fetch records from subcollections with query parameters', | ||
async ({ simulateQueryFilters, expectedSize }) => { | ||
const family = db(simulateQueryFilters) | ||
.collection('characters') | ||
.doc('bob') | ||
.collection('family') | ||
.where('relation', '==', 'son'); // should return only sons | ||
expect(family).toHaveProperty('path', 'characters/bob/family'); | ||
const docs = await family.get(); | ||
expect(docs).toHaveProperty('size', 4); | ||
}); | ||
const docs = await family.get(); | ||
expect(docs).toHaveProperty('size', expectedSize); | ||
}, | ||
); | ||
}); | ||
@@ -241,5 +286,7 @@ | ||
expect.assertions(4); | ||
const characters = await db.collection('characters').get(); | ||
expect(characters.empty).toBe(false); | ||
expect(characters.size).toBe(3); | ||
const characters = await db() | ||
.collection('characters') | ||
.get(); | ||
expect(characters).toHaveProperty('empty', false); | ||
expect(characters).toHaveProperty('size', 3); | ||
expect(Array.isArray(characters.docs)).toBe(true); | ||
@@ -251,5 +298,7 @@ expect(characters.forEach).toBeTruthy(); | ||
expect.assertions(4); | ||
const nope = await db.collection('foo').get(); | ||
expect(nope.empty).toBe(true); | ||
expect(nope.size).toBe(0); | ||
const nope = await db() | ||
.collection('foo') | ||
.get(); | ||
expect(nope).toHaveProperty('empty', true); | ||
expect(nope).toHaveProperty('size', 0); | ||
expect(Array.isArray(nope.docs)).toBe(true); | ||
@@ -261,3 +310,3 @@ expect(nope.forEach).toBeTruthy(); | ||
expect.assertions(4); | ||
const familyRef = db | ||
const familyRef = db() | ||
.collection('characters') | ||
@@ -267,4 +316,4 @@ .doc('bob') | ||
const family = await familyRef.get(); | ||
expect(family.empty).toBe(false); | ||
expect(family.size).toBe(4); | ||
expect(family).toHaveProperty('empty', false); | ||
expect(family).toHaveProperty('size', 4); | ||
expect(Array.isArray(family.docs)).toBe(true); | ||
@@ -276,3 +325,3 @@ expect(family.forEach).toBeTruthy(); | ||
expect.assertions(4); | ||
const nope = await db | ||
const nope = await db() | ||
.collection('characters') | ||
@@ -282,4 +331,4 @@ .doc('bob') | ||
.get(); | ||
expect(nope.empty).toBe(true); | ||
expect(nope.size).toBe(0); | ||
expect(nope).toHaveProperty('empty', true); | ||
expect(nope).toHaveProperty('size', 0); | ||
expect(Array.isArray(nope.docs)).toBe(true); | ||
@@ -291,3 +340,3 @@ expect(nope.forEach).toBeTruthy(); | ||
expect.assertions(4); | ||
const nope = await db | ||
const nope = await db() | ||
.collection('foo') | ||
@@ -297,4 +346,4 @@ .doc('bar') | ||
.get(); | ||
expect(nope.empty).toBe(true); | ||
expect(nope.size).toBe(0); | ||
expect(nope).toHaveProperty('empty', true); | ||
expect(nope).toHaveProperty('size', 0); | ||
expect(Array.isArray(nope.docs)).toBe(true); | ||
@@ -306,10 +355,11 @@ expect(nope.forEach).toBeTruthy(); | ||
test('New documents with random ID', async () => { | ||
expect.assertions(1); | ||
expect.assertions(2); | ||
// See https://firebase.google.com/docs/reference/js/firebase.firestore.CollectionReference#doc | ||
// "If no path is specified, an automatically-generated unique ID will be used for the returned DocumentReference." | ||
const col = db.collection('characters'); | ||
const col = db().collection('foo'); | ||
const newDoc = col.doc(); | ||
const otherIds = col.records().map(doc => doc.id); | ||
const otherIds = col._records().map(doc => doc.id); | ||
expect(otherIds).not.toContainEqual(newDoc.id); | ||
expect(newDoc).toHaveProperty('path', `foo/${newDoc.id}`); | ||
}); | ||
}); |
@@ -11,14 +11,119 @@ const { | ||
describe('test', () => { | ||
mockFirebase({ | ||
database: { | ||
animals: [ | ||
{ id: 'monkey', name: 'monkey', type: 'mammal' }, | ||
{ id: 'elephant', name: 'elephant', type: 'mammal' }, | ||
{ id: 'chicken', name: 'chicken', type: 'bird' }, | ||
{ id: 'ant', name: 'ant', type: 'insect' }, | ||
], | ||
describe('Queries', () => { | ||
mockFirebase( | ||
{ | ||
database: { | ||
animals: [ | ||
{ | ||
id: 'monkey', | ||
name: 'monkey', | ||
type: 'mammal', | ||
legCount: 2, | ||
food: ['banana', 'mango'], | ||
foodCount: 1, | ||
foodEaten: [500, 20], | ||
}, | ||
{ | ||
id: 'elephant', | ||
name: 'elephant', | ||
type: 'mammal', | ||
legCount: 4, | ||
food: ['banana', 'peanut'], | ||
foodCount: 0, | ||
foodEaten: [0, 500], | ||
}, | ||
{ | ||
id: 'chicken', | ||
name: 'chicken', | ||
type: 'bird', | ||
legCount: 2, | ||
food: ['leaf', 'nut', 'ant'], | ||
foodCount: 4, | ||
foodEaten: [80, 20, 16], | ||
_collections: { | ||
foodSchedule: [ | ||
{ | ||
id: 'nut', | ||
interval: 'whenever', | ||
}, | ||
{ | ||
id: 'leaf', | ||
interval: 'hourly', | ||
}, | ||
], | ||
}, | ||
}, | ||
{ | ||
id: 'ant', | ||
name: 'ant', | ||
type: 'insect', | ||
legCount: 6, | ||
food: ['leaf', 'bread'], | ||
foodCount: 2, | ||
foodEaten: [80, 12], | ||
_collections: { | ||
foodSchedule: [ | ||
{ | ||
id: 'leaf', | ||
interval: 'daily', | ||
}, | ||
{ | ||
id: 'peanut', | ||
interval: 'weekly', | ||
}, | ||
], | ||
}, | ||
}, | ||
{ | ||
id: 'worm', | ||
name: 'worm', | ||
legCount: null, | ||
}, | ||
{ | ||
id: 'pogo-stick', | ||
name: 'pogo-stick', | ||
food: false, | ||
}, | ||
], | ||
foodSchedule: [ | ||
{ id: 'ants', interval: 'daily' }, | ||
{ id: 'cows', interval: 'twice daily' }, | ||
], | ||
nested: [ | ||
{ | ||
id: 'collections', | ||
_collections: { | ||
have: [ | ||
{ | ||
id: 'lots', | ||
_collections: { | ||
of: [ | ||
{ | ||
id: 'applications', | ||
_collections: { | ||
foodSchedule: [ | ||
{ | ||
id: 'layer4_a', | ||
interval: 'daily', | ||
}, | ||
{ | ||
id: 'layer4_b', | ||
interval: 'weekly', | ||
}, | ||
], | ||
}, | ||
}, | ||
], | ||
}, | ||
}, | ||
], | ||
}, | ||
}, | ||
], | ||
}, | ||
currentUser: { uid: 'homer-user' }, | ||
}, | ||
currentUser: { uid: 'homer-user' }, | ||
}); | ||
{ simulateQueryFilters: true }, | ||
); | ||
const firebase = require('firebase'); | ||
@@ -45,4 +150,28 @@ firebase.initializeApp({ | ||
test('it can query null values', async () => { | ||
const noLegs = await db | ||
.collection('animals') | ||
.where('legCount', '==', null) | ||
.get(); | ||
expect(noLegs).toHaveProperty('size', 1); | ||
const worm = noLegs.docs[0]; | ||
expect(worm).toBeDefined(); | ||
expect(worm).toHaveProperty('id', 'worm'); | ||
}); | ||
test('it can query false values', async () => { | ||
const noFood = await db | ||
.collection('animals') | ||
.where('food', '==', false) | ||
.get(); | ||
expect(noFood).toHaveProperty('size', 1); | ||
const pogoStick = noFood.docs[0]; | ||
expect(pogoStick).toBeDefined(); | ||
expect(pogoStick).toHaveProperty('id', 'pogo-stick'); | ||
}); | ||
test('it can query multiple documents', async () => { | ||
expect.assertions(10); | ||
expect.assertions(9); | ||
const animals = await db | ||
@@ -54,3 +183,7 @@ .collection('animals') | ||
expect(animals).toHaveProperty('docs', expect.any(Array)); | ||
expect(animals.docs.length).toBe(4); | ||
expect(mockCollection).toHaveBeenCalledWith('animals'); | ||
// Make sure that the filter behaves appropriately | ||
expect(animals.docs.length).toBe(2); | ||
// Make sure that forEach works properly | ||
@@ -64,6 +197,102 @@ expect(animals).toHaveProperty('forEach', expect.any(Function)); | ||
expect(mockWhere).toHaveBeenCalledWith('type', '==', 'mammal'); | ||
expect(mockGet).toHaveBeenCalled(); | ||
expect(animals).toHaveProperty('size', 2); // Returns 2 of 4 documents | ||
}); | ||
test('it can filter firestore equality queries in subcollections', async () => { | ||
const antSchedule = await db | ||
.collection('animals') | ||
.doc('ant') | ||
.collection('foodSchedule') | ||
.where('interval', '==', 'daily') | ||
.get(); | ||
expect(mockCollection).toHaveBeenCalledWith('animals'); | ||
expect(mockCollection).toHaveBeenCalledWith('foodSchedule'); | ||
expect(mockWhere).toHaveBeenCalledWith('interval', '==', 'daily'); | ||
expect(mockGet).toHaveBeenCalled(); | ||
expect(antSchedule).toHaveProperty('docs', expect.any(Array)); | ||
expect(antSchedule).toHaveProperty('size', 1); // Returns 1 of 2 documents | ||
}); | ||
test('in a transaction, it can filter firestore equality queries in subcollections', async () => { | ||
mockGet.mockReset(); | ||
const antSchedule = db | ||
.collection('animals') | ||
.doc('ant') | ||
.collection('foodSchedule') | ||
.where('interval', '==', 'daily'); | ||
expect.assertions(6); | ||
await db.runTransaction(async transaction => { | ||
const scheduleItems = await transaction.get(antSchedule); | ||
expect(mockCollection).toHaveBeenCalledWith('animals'); | ||
expect(mockCollection).toHaveBeenCalledWith('foodSchedule'); | ||
expect(mockWhere).toHaveBeenCalledWith('interval', '==', 'daily'); | ||
expect(mockGet).not.toHaveBeenCalled(); | ||
expect(scheduleItems).toHaveProperty('docs', expect.any(Array)); | ||
expect(scheduleItems).toHaveProperty('size', 1); // Returns 1 of 2 documents | ||
}); | ||
}); | ||
test('it can filter firestore comparison queries in subcollections', async () => { | ||
const chickenSchedule = db | ||
.collection('animals') | ||
.doc('chicken') | ||
.collection('foodSchedule') | ||
.where('interval', '<=', 'hourly'); // should have 1 result | ||
const scheduleItems = await chickenSchedule.get(); | ||
expect(scheduleItems).toHaveProperty('docs', expect.any(Array)); | ||
expect(scheduleItems).toHaveProperty('size', 1); // Returns 1 document | ||
expect(scheduleItems.docs[0]).toHaveProperty( | ||
'ref', | ||
expect.any(FakeFirestore.DocumentReference), | ||
); | ||
expect(scheduleItems.docs[0]).toHaveProperty('id', 'leaf'); | ||
expect(scheduleItems.docs[0].data()).toHaveProperty('interval', 'hourly'); | ||
expect(scheduleItems.docs[0].ref).toHaveProperty('path', 'animals/chicken/foodSchedule/leaf'); | ||
}); | ||
test('in a transaction, it can filter firestore comparison queries in subcollections', async () => { | ||
const chickenSchedule = db | ||
.collection('animals') | ||
.doc('chicken') | ||
.collection('foodSchedule') | ||
.where('interval', '<=', 'hourly'); // should have 1 result | ||
expect.assertions(6); | ||
await db.runTransaction(async transaction => { | ||
const scheduleItems = await transaction.get(chickenSchedule); | ||
expect(scheduleItems).toHaveProperty('docs', expect.any(Array)); | ||
expect(scheduleItems).toHaveProperty('size', 1); // Returns 1 document | ||
expect(scheduleItems.docs[0]).toHaveProperty( | ||
'ref', | ||
expect.any(FakeFirestore.DocumentReference), | ||
); | ||
expect(scheduleItems.docs[0]).toHaveProperty('id', 'leaf'); | ||
expect(scheduleItems.docs[0].data()).toHaveProperty('interval', 'hourly'); | ||
expect(scheduleItems.docs[0].ref).toHaveProperty('path', 'animals/chicken/foodSchedule/leaf'); | ||
}); | ||
}); | ||
test('it can query collection groups', async () => { | ||
const allSchedules = await db.collectionGroup('foodSchedule').get(); | ||
expect(allSchedules).toHaveProperty('size', 8); // Returns all 8 | ||
const paths = allSchedules.docs.map(doc => doc.ref.path).sort(); | ||
const expectedPaths = [ | ||
'nested/collections/have/lots/of/applications/foodSchedule/layer4_a', | ||
'nested/collections/have/lots/of/applications/foodSchedule/layer4_b', | ||
'animals/ant/foodSchedule/leaf', | ||
'animals/ant/foodSchedule/peanut', | ||
'animals/chicken/foodSchedule/leaf', | ||
'animals/chicken/foodSchedule/nut', | ||
'foodSchedule/ants', | ||
'foodSchedule/cows', | ||
].sort(); | ||
expect(paths).toStrictEqual(expectedPaths); | ||
}); | ||
test('it returns the same instance from query methods', () => { | ||
@@ -93,2 +322,18 @@ const ref = db.collection('animals'); | ||
test('it throws an error when comparing to null', () => { | ||
expect(() => db.collection('animals').where('legCount', '>', null)).toThrow(); | ||
expect(() => db.collection('animals').where('legCount', '>=', null)).toThrow(); | ||
expect(() => db.collection('animals').where('legCount', '<', null)).toThrow(); | ||
expect(() => db.collection('animals').where('legCount', '<=', null)).toThrow(); | ||
expect(() => db.collection('animals').where('legCount', 'array-contains', null)).toThrow(); | ||
expect(() => db.collection('animals').where('legCount', 'array-contains-any', null)).toThrow(); | ||
expect(() => db.collection('animals').where('legCount', 'in', null)).toThrow(); | ||
expect(() => db.collection('animals').where('legCount', 'not-in', null)).toThrow(); | ||
}); | ||
test('it allows equality comparisons with null', () => { | ||
expect(() => db.collection('animals').where('legCount', '==', null)).not.toThrow(); | ||
expect(() => db.collection('animals').where('legCount', '!=', null)).not.toThrow(); | ||
}); | ||
test('it permits mocking the results of a where clause', async () => { | ||
@@ -99,3 +344,3 @@ expect.assertions(2); | ||
let result = await ref.where('type', '==', 'mammal').get(); | ||
expect(result.docs.length).toBe(4); | ||
expect(result.docs.length).toBe(2); | ||
@@ -117,3 +362,3 @@ // There's got to be a better way to mock like this, but at least it works. | ||
test('It can offset query', async () => { | ||
test('it can offset query', async () => { | ||
const firstTwoMammals = await db | ||
@@ -131,2 +376,149 @@ .collection('animals') | ||
}); | ||
describe('Query Operations', () => { | ||
test.each` | ||
comp | value | count | ||
${'=='} | ${2} | ${2} | ||
${'=='} | ${4} | ${1} | ||
${'=='} | ${6} | ${1} | ||
${'=='} | ${7} | ${0} | ||
${'!='} | ${7} | ${5} | ||
${'!='} | ${4} | ${4} | ||
${'>'} | ${1000} | ${0} | ||
${'>'} | ${1} | ${4} | ||
${'>'} | ${6} | ${0} | ||
${'>='} | ${1000} | ${0} | ||
${'>='} | ${6} | ${1} | ||
${'>='} | ${0} | ${4} | ||
${'<'} | ${-10000} | ${0} | ||
${'<'} | ${10000} | ${4} | ||
${'<'} | ${2} | ${0} | ||
${'<'} | ${6} | ${3} | ||
${'<='} | ${-10000} | ${0} | ||
${'<='} | ${10000} | ${4} | ||
${'<='} | ${2} | ${2} | ||
${'<='} | ${6} | ${4} | ||
${'in'} | ${[6, 2]} | ${3} | ||
${'not-in'} | ${[6, 2]} | ${2} | ||
${'not-in'} | ${[4]} | ${4} | ||
${'not-in'} | ${[7]} | ${5} | ||
`( | ||
// eslint-disable-next-line quotes | ||
"it performs '$comp' queries on number values ($count doc(s) where legCount $comp $value)", | ||
async ({ comp, value, count }) => { | ||
const results = await db | ||
.collection('animals') | ||
.where('legCount', comp, value) | ||
.get(); | ||
expect(results.size).toBe(count); | ||
}, | ||
); | ||
test.each` | ||
comp | value | count | ||
${'=='} | ${0} | ${1} | ||
${'=='} | ${1} | ${1} | ||
${'=='} | ${2} | ${1} | ||
${'=='} | ${4} | ${1} | ||
${'=='} | ${6} | ${0} | ||
${'>'} | ${-1} | ${4} | ||
${'>'} | ${0} | ${3} | ||
${'>'} | ${1} | ${2} | ||
${'>'} | ${4} | ${0} | ||
${'>='} | ${6} | ${0} | ||
${'>='} | ${4} | ${1} | ||
${'>='} | ${0} | ${4} | ||
${'<'} | ${2} | ${2} | ||
${'<'} | ${6} | ${4} | ||
${'<='} | ${2} | ${3} | ||
${'<='} | ${6} | ${4} | ||
${'in'} | ${[2, 0]} | ${2} | ||
${'not-in'} | ${[2, 0]} | ${2} | ||
`( | ||
// eslint-disable-next-line quotes | ||
"it performs '$comp' queries on number values that may be zero ($count doc(s) where foodCount $comp $value)", | ||
async ({ comp, value, count }) => { | ||
const results = await db | ||
.collection('animals') | ||
.where('foodCount', comp, value) | ||
.get(); | ||
expect(results.size).toBe(count); | ||
}, | ||
); | ||
test.each` | ||
comp | value | count | ||
${'=='} | ${'mammal'} | ${2} | ||
${'=='} | ${'bird'} | ${1} | ||
${'=='} | ${'fish'} | ${0} | ||
${'!='} | ${'bird'} | ${3} | ||
${'!='} | ${'fish'} | ${4} | ||
${'>'} | ${'insect'} | ${2} | ||
${'>'} | ${'z'} | ${0} | ||
${'>='} | ${'mammal'} | ${2} | ||
${'>='} | ${'insect'} | ${3} | ||
${'<'} | ${'bird'} | ${0} | ||
${'<'} | ${'mammal'} | ${2} | ||
${'<='} | ${'mammal'} | ${4} | ||
${'<='} | ${'bird'} | ${1} | ||
${'<='} | ${'a'} | ${0} | ||
${'in'} | ${['a', 'bird', 'mammal']} | ${3} | ||
${'not-in'} | ${['a', 'bird', 'mammal']} | ${1} | ||
`( | ||
// eslint-disable-next-line quotes | ||
"it performs '$comp' queries on string values ($count doc(s) where type $comp '$value')", | ||
async ({ comp, value, count }) => { | ||
const results = await db | ||
.collection('animals') | ||
.where('type', comp, value) | ||
.get(); | ||
expect(results.size).toBe(count); | ||
}, | ||
); | ||
test.each` | ||
comp | value | count | ||
${'=='} | ${['banana', 'mango']} | ${1} | ||
${'=='} | ${['mango', 'banana']} | ${0} | ||
${'=='} | ${['banana', 'peanut']} | ${1} | ||
${'!='} | ${['banana', 'peanut']} | ${4} | ||
${'array-contains'} | ${'banana'} | ${2} | ||
${'array-contains'} | ${'leaf'} | ${2} | ||
${'array-contains'} | ${'bread'} | ${1} | ||
${'array-contains-any'} | ${['banana', 'mango', 'peanut']} | ${2} | ||
`( | ||
// eslint-disable-next-line quotes | ||
"it performs '$comp' queries on array values ($count doc(s) where food $comp '$value')", | ||
async ({ comp, value, count }) => { | ||
const results = await db | ||
.collection('animals') | ||
.where('food', comp, value) | ||
.get(); | ||
expect(results.size).toBe(count); | ||
}, | ||
); | ||
test.each` | ||
comp | value | count | ||
${'=='} | ${[500, 20]} | ${1} | ||
${'=='} | ${[20, 500]} | ${0} | ||
${'=='} | ${[0, 500]} | ${1} | ||
${'!='} | ${[20, 500]} | ${4} | ||
${'array-contains'} | ${500} | ${2} | ||
${'array-contains'} | ${80} | ${2} | ||
${'array-contains'} | ${12} | ${1} | ||
${'array-contains'} | ${0} | ${1} | ||
${'array-contains-any'} | ${[0, 11, 500]} | ${2} | ||
`( | ||
// eslint-disable-next-line quotes | ||
"it performs '$comp' queries on array values that may be zero ($count doc(s) where foodEaten $comp '$value')", | ||
async ({ comp, value, count }) => { | ||
const results = await db | ||
.collection('animals') | ||
.where('foodEaten', comp, value) | ||
.get(); | ||
expect(results.size).toBe(count); | ||
}, | ||
); | ||
}); | ||
}); |
@@ -52,4 +52,4 @@ const { FakeFirestore } = require('firestore-jest-mock'); | ||
expect(collectionRef.firestore).toBe(db); | ||
expect(collectionRef.id).toBe('characters'); | ||
expect(collectionRef.path).toBe('database/characters'); | ||
expect(collectionRef).toHaveProperty('id', 'characters'); | ||
expect(collectionRef).toHaveProperty('path', 'characters'); | ||
@@ -94,3 +94,3 @@ const other = db.collection('characters'); | ||
expect(homerRef).toBeInstanceOf(FakeFirestore.DocumentReference); | ||
expect(homerRef.parent).toBeInstanceOf(FakeFirestore.CollectionReference); | ||
expect(homerRef).toHaveProperty('parent', expect.any(FakeFirestore.CollectionReference)); | ||
expect(mockCollection).toHaveBeenCalledWith('characters'); | ||
@@ -109,4 +109,4 @@ expect(mockDoc).toHaveBeenCalledWith('homer'); | ||
expect(docRef.firestore).toBe(db); | ||
expect(docRef.id).toBe('homer'); | ||
expect(docRef.path).toBe('database/characters/homer'); | ||
expect(docRef).toHaveProperty('id', 'homer'); | ||
expect(docRef).toHaveProperty('path', 'characters/homer'); | ||
@@ -113,0 +113,0 @@ const other = db.collection('characters').doc('homer'); |
@@ -66,4 +66,4 @@ const { mockFirebase, FakeFirestore } = require('firestore-jest-mock'); | ||
// Calling `get` on transaction also calls `get` on `ref` | ||
expect(mockGet).toHaveBeenCalled(); | ||
// Calling `get` on transaction no longer calls `get` on `ref` | ||
expect(mockGet).not.toHaveBeenCalled(); | ||
expect(doc).toHaveProperty('id', 'body'); | ||
@@ -70,0 +70,0 @@ expect(doc).toHaveProperty('exists', false); |
export { FakeFirestore } from './mocks/firestore'; | ||
export { FakeAuth } from './mocks/auth'; | ||
export { mockFirebase } from './mocks/firebase'; | ||
export { mockGoogleCloudFirestore } from './mocks/googleCloudFirestore'; |
@@ -10,2 +10,4 @@ export const mockCreateUserWithEmailAndPassword: jest.Mock; | ||
export const mockSignOut: jest.Mock; | ||
// FIXME: We should decide whether this should be exported from auth or firestore | ||
export const mockUseEmulator: jest.Mock; | ||
@@ -12,0 +14,0 @@ |
@@ -21,5 +21,4 @@ import type { FirebaseUser, FakeAuth } from './auth'; | ||
export interface StubOptions { | ||
includeIdsInData?: boolean; | ||
} | ||
type DefaultOptions = typeof import('./helpers/defaultMockOptions'); | ||
export interface StubOptions extends Partial<DefaultOptions> {} | ||
@@ -26,0 +25,0 @@ export interface FirebaseMock { |
@@ -114,6 +114,6 @@ import type { FieldValue } from './fieldValue'; | ||
*/ | ||
private records(): Array<MockedDocument> | ||
private _records(): Array<MockedDocument> | ||
} | ||
// Mocks defined here | ||
// Mocks exported from this module | ||
export const mockBatch: jest.Mock; | ||
@@ -130,2 +130,4 @@ export const mockRunTransaction: jest.Mock; | ||
export const mockSettings: jest.Mock; | ||
// FIXME: We should decide whether this should be exported from auth or firestore | ||
export const mockUseEmulator: jest.Mock; | ||
@@ -132,0 +134,0 @@ export const mockListDocuments: jest.Mock; |
@@ -89,10 +89,35 @@ const mockCollectionGroup = jest.fn(); | ||
collection(collectionName) { | ||
collection(path) { | ||
// Accept any collection path | ||
// See https://firebase.google.com/docs/reference/js/firebase.firestore.Firestore#collection | ||
mockCollection(...arguments); | ||
return new FakeFirestore.CollectionReference(collectionName, null, this); | ||
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(collectionName) { | ||
collectionGroup(collectionId) { | ||
mockCollectionGroup(...arguments); | ||
return new FakeFirestore.Query(collectionName, this); | ||
return new FakeFirestore.Query(collectionId, this, true); | ||
} | ||
@@ -102,21 +127,48 @@ | ||
mockDoc(path); | ||
return this._doc(path); | ||
} | ||
_doc(path) { | ||
// Accept any document path | ||
// See https://firebase.google.com/docs/reference/js/firebase.firestore.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) { | ||
throw new Error('The path array must be document-level'); | ||
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; | ||
for (let index = 0; index < pathArray.length; index++) { | ||
const collectionId = pathArray[index]; | ||
const documentId = pathArray[index + 1]; | ||
let coll = null; | ||
for (let index = 0; index < pathArray.length; index += 2) { | ||
const collectionId = pathArray[index] || ''; | ||
const documentId = pathArray[index + 1] || ''; | ||
const collection = new FakeFirestore.CollectionReference(collectionId, doc, this); | ||
doc = new FakeFirestore.DocumentReference(documentId, collection); | ||
coll = new FakeFirestore.CollectionReference(collectionId, doc, this); | ||
if (!documentId) { | ||
break; | ||
} | ||
doc = new FakeFirestore.DocumentReference(documentId, coll); | ||
} | ||
index++; // skip to next collection | ||
} | ||
return doc; | ||
return { doc, coll }; | ||
} | ||
@@ -136,9 +188,9 @@ | ||
// note: this logic could be deduplicated | ||
const pathArray = path | ||
.replace(/^\/+/, '') | ||
.split('/') | ||
.slice(1); | ||
const pathArray = path.replace(/^\/+/, '').split('/'); | ||
// Must be document-level, so even-numbered elements | ||
if (pathArray.length % 2) { | ||
throw new Error('The path array must be document-level'); | ||
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}`, | ||
); | ||
} | ||
@@ -194,3 +246,6 @@ | ||
this.firestore = parent.firestore; | ||
this.path = parent.path.concat(`/${id}`); | ||
this.path = parent.path | ||
.split('/') | ||
.concat(id) | ||
.join('/'); | ||
} | ||
@@ -223,11 +278,9 @@ | ||
this.get() | ||
.then(result => { | ||
callback(result); | ||
}) | ||
.catch(e => { | ||
throw e; | ||
}); | ||
callback(this._get()); | ||
} catch (e) { | ||
errorCallback(e); | ||
if (errorCallback) { | ||
errorCallback(e); | ||
} else { | ||
throw e; | ||
} | ||
} | ||
@@ -291,3 +344,6 @@ | ||
pathArray.shift(); // drop 'database'; it's always first | ||
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()]; | ||
@@ -299,3 +355,3 @@ let document = null; | ||
} else { | ||
return { exists: false, data: () => undefined, id: this.id }; | ||
return { exists: false, data: () => undefined, id: this.id, ref: this }; | ||
} | ||
@@ -308,7 +364,7 @@ | ||
if (!document || !document._collections) { | ||
return { exists: false, data: () => undefined, id: this.id }; | ||
return { exists: false, data: () => undefined, id: this.id, ref: this }; | ||
} | ||
requestedRecords = document._collections[collectionId] || []; | ||
if (requestedRecords.length === 0) { | ||
return { exists: false, data: () => undefined, id: this.id }; | ||
return { exists: false, data: () => undefined, id: this.id, ref: this }; | ||
} | ||
@@ -318,3 +374,3 @@ | ||
if (!document) { | ||
return { exists: false, data: () => undefined, id: this.id }; | ||
return { exists: false, data: () => undefined, id: this.id, ref: this }; | ||
} | ||
@@ -353,3 +409,3 @@ | ||
} else { | ||
this.path = `database/${id}`; | ||
this.path = id; | ||
} | ||
@@ -371,3 +427,2 @@ } | ||
/** | ||
* @function records | ||
* A private method, meant mainly to be used by `get` and other internal objects to retrieve | ||
@@ -377,7 +432,6 @@ * the list of database records referenced by this CollectionReference. | ||
*/ | ||
records() { | ||
_records() { | ||
// Ignore leading slash | ||
const pathArray = this.path.replace(/^\/+/, '').split('/'); | ||
pathArray.shift(); // drop 'database'; it's always first | ||
let requestedRecords = this.firestore.database[pathArray.shift()]; | ||
@@ -420,8 +474,13 @@ if (pathArray.length === 0) { | ||
query.mocks.mockGet(...arguments); | ||
return Promise.resolve(this._get()); | ||
} | ||
_get() { | ||
// Make sure we have a 'good enough' document reference | ||
const records = this.records(); | ||
const records = this._records(); | ||
records.forEach(rec => { | ||
rec._ref = this.doc(rec.id); | ||
rec._ref = new FakeFirestore.DocumentReference(rec.id, this, this.firestore); | ||
}); | ||
return Promise.resolve(buildQuerySnapShot(records)); | ||
const isFilteringEnabled = this.firestore.options.simulateQueryFilters; | ||
return buildQuerySnapShot(records, isFilteringEnabled ? this.filters : undefined); | ||
} | ||
@@ -428,0 +487,0 @@ |
import type { MockedDocument, DocumentHash } from './buildDocFromHash'; | ||
export type Comparator = '<' | '<=' | '==' | '!=' | '>=' | '>' | 'array-contains' | 'in' | 'not-in' | 'array-contains-any'; | ||
export interface QueryFilter { | ||
key: string; | ||
comp: Comparator; | ||
value: string; | ||
} | ||
export interface MockedQuerySnapshot<Doc = MockedDocument> { | ||
@@ -15,3 +23,7 @@ empty: boolean; | ||
* @param requestedRecords | ||
* @param filters | ||
*/ | ||
export default function buildQuerySnapShot(requestedRecords: Array<DocumentHash>): MockedQuerySnapshot; | ||
export default function buildQuerySnapShot( | ||
requestedRecords: Array<DocumentHash>, | ||
filters?: Array<QueryFilter> | ||
): MockedQuerySnapshot; |
const buildDocFromHash = require('./buildDocFromHash'); | ||
module.exports = function buildQuerySnapShot(requestedRecords) { | ||
const multipleRecords = requestedRecords.filter(rec => !!rec); | ||
const docs = multipleRecords.map(buildDocFromHash); | ||
module.exports = function buildQuerySnapShot(requestedRecords, filters) { | ||
const definiteRecords = requestedRecords.filter(rec => !!rec); | ||
const results = _filteredDocuments(definiteRecords, filters); | ||
const docs = results.map(buildDocFromHash); | ||
return { | ||
empty: multipleRecords.length < 1, | ||
size: multipleRecords.length, | ||
empty: results.length < 1, | ||
size: results.length, | ||
docs, | ||
@@ -19,1 +20,237 @@ forEach(callback) { | ||
}; | ||
/** | ||
* @typedef DocumentHash | ||
* @type {import('./buildDocFromHash').DocumentHash} | ||
*/ | ||
/** | ||
* @typedef Comparator | ||
* @type {import('./buildQuerySnapShot').Comparator} | ||
*/ | ||
/** | ||
* Applies query filters to an array of mock document data. | ||
* | ||
* @param {Array<DocumentHash>} records The array of records to filter. | ||
* @param {Array<{ key: string; comp: Comparator; value: unknown }>=} filters The filters to apply. | ||
* If no filters are provided, then the records array is returned as-is. | ||
* | ||
* @returns {Array<import('./buildDocFromHash').DocumentHash>} The filtered documents. | ||
*/ | ||
function _filteredDocuments(records, filters) { | ||
if (!filters || !Array.isArray(filters) || filters.length === 0) { | ||
return records; | ||
} | ||
filters.forEach(({ key, comp, value }) => { | ||
// https://firebase.google.com/docs/reference/js/firebase.firestore#wherefilterop | ||
// Convert values to string to make Array comparisons work | ||
// See https://jsbin.com/bibawaf/edit?js,console | ||
switch (comp) { | ||
// https://firebase.google.com/docs/firestore/query-data/queries#query_operators | ||
case '<': | ||
records = _recordsLessThanValue(records, key, value); | ||
break; | ||
case '<=': | ||
records = _recordsLessThanOrEqualToValue(records, key, value); | ||
break; | ||
case '==': | ||
records = _recordsEqualToValue(records, key, value); | ||
break; | ||
case '!=': | ||
records = _recordsNotEqualToValue(records, key, value); | ||
break; | ||
case '>=': | ||
records = _recordsGreaterThanOrEqualToValue(records, key, value); | ||
break; | ||
case '>': | ||
records = _recordsGreaterThanValue(records, key, value); | ||
break; | ||
case 'array-contains': | ||
records = _recordsArrayContainsValue(records, key, value); | ||
break; | ||
case 'in': | ||
records = _recordsWithValueInList(records, key, value); | ||
break; | ||
case 'not-in': | ||
records = _recordsWithValueNotInList(records, key, value); | ||
break; | ||
case 'array-contains-any': | ||
records = _recordsWithOneOfValues(records, key, value); | ||
break; | ||
} | ||
}); | ||
return records; | ||
} | ||
function _recordsWithKey(records, key) { | ||
return records.filter(record => record && record[key] !== undefined); | ||
} | ||
function _recordsWithNonNullKey(records, key) { | ||
return records.filter(record => record && record[key] !== undefined && record[key] !== null); | ||
} | ||
function _shouldCompareNumerically(a, b) { | ||
return typeof a === 'number' && typeof b === 'number'; | ||
} | ||
/** | ||
* @param {Array<DocumentHash>} records | ||
* @param {string} key | ||
* @param {unknown} value | ||
* @returns {Array<DocumentHash>} | ||
*/ | ||
function _recordsLessThanValue(records, key, value) { | ||
return _recordsWithNonNullKey(records, key).filter(record => { | ||
if (_shouldCompareNumerically(record[key], value)) { | ||
return record[key] < value; | ||
} | ||
return String(record[key]) < String(value); | ||
}); | ||
} | ||
/** | ||
* @param {Array<DocumentHash>} records | ||
* @param {string} key | ||
* @param {unknown} value | ||
* @returns {Array<DocumentHash>} | ||
*/ | ||
function _recordsLessThanOrEqualToValue(records, key, value) { | ||
return _recordsWithNonNullKey(records, key).filter(record => { | ||
if (_shouldCompareNumerically(record[key], value)) { | ||
return record[key] <= value; | ||
} | ||
return String(record[key]) <= String(value); | ||
}); | ||
} | ||
/** | ||
* @param {Array<DocumentHash>} records | ||
* @param {string} key | ||
* @param {unknown} value | ||
* @returns {Array<DocumentHash>} | ||
*/ | ||
function _recordsEqualToValue(records, key, value) { | ||
return _recordsWithKey(records, key).filter(record => String(record[key]) === String(value)); | ||
} | ||
/** | ||
* @param {Array<DocumentHash>} records | ||
* @param {string} key | ||
* @param {unknown} value | ||
* @returns {Array<DocumentHash>} | ||
*/ | ||
function _recordsNotEqualToValue(records, key, value) { | ||
return _recordsWithKey(records, key).filter(record => String(record[key]) !== String(value)); | ||
} | ||
/** | ||
* @param {Array<DocumentHash>} records | ||
* @param {string} key | ||
* @param {unknown} value | ||
* @returns {Array<DocumentHash>} | ||
*/ | ||
function _recordsGreaterThanOrEqualToValue(records, key, value) { | ||
return _recordsWithNonNullKey(records, key).filter(record => { | ||
if (_shouldCompareNumerically(record[key], value)) { | ||
return record[key] >= value; | ||
} | ||
return String(record[key]) >= String(value); | ||
}); | ||
} | ||
/** | ||
* @param {Array<DocumentHash>} records | ||
* @param {string} key | ||
* @param {unknown} value | ||
* @returns {Array<DocumentHash>} | ||
*/ | ||
function _recordsGreaterThanValue(records, key, value) { | ||
return _recordsWithNonNullKey(records, key).filter(record => { | ||
if (_shouldCompareNumerically(record[key], value)) { | ||
return record[key] > value; | ||
} | ||
return String(record[key]) > String(value); | ||
}); | ||
} | ||
/** | ||
* @see https://firebase.google.com/docs/firestore/query-data/queries#array_membership | ||
* | ||
* @param {Array<DocumentHash>} records | ||
* @param {string} key | ||
* @param {unknown} value | ||
* @returns {Array<DocumentHash>} | ||
*/ | ||
function _recordsArrayContainsValue(records, key, value) { | ||
return records.filter( | ||
record => record && record[key] && Array.isArray(record[key]) && record[key].includes(value), | ||
); | ||
} | ||
/** | ||
* @see https://firebase.google.com/docs/firestore/query-data/queries#in_not-in_and_array-contains-any | ||
* | ||
* @param {Array<DocumentHash>} records | ||
* @param {string} key | ||
* @param {unknown} value | ||
* @returns {Array<DocumentHash>} | ||
*/ | ||
function _recordsWithValueInList(records, key, value) { | ||
// TODO: Throw an error when a value is passed that contains more than 10 values | ||
return records.filter(record => { | ||
if (!record || record[key] === undefined) { | ||
return false; | ||
} | ||
return value && Array.isArray(value) && value.includes(record[key]); | ||
}); | ||
} | ||
/** | ||
* @see https://firebase.google.com/docs/firestore/query-data/queries#not-in | ||
* | ||
* @param {Array<DocumentHash>} records | ||
* @param {string} key | ||
* @param {unknown} value | ||
* @returns {Array<DocumentHash>} | ||
*/ | ||
function _recordsWithValueNotInList(records, key, value) { | ||
// TODO: Throw an error when a value is passed that contains more than 10 values | ||
return _recordsWithKey(records, key).filter( | ||
record => value && Array.isArray(value) && !value.includes(record[key]), | ||
); | ||
} | ||
/** | ||
* @see https://firebase.google.com/docs/firestore/query-data/queries#in_not-in_and_array-contains-any | ||
* | ||
* @param {Array<DocumentHash>} records | ||
* @param {string} key | ||
* @param {unknown} value | ||
* @returns {Array<DocumentHash>} | ||
*/ | ||
function _recordsWithOneOfValues(records, key, value) { | ||
// TODO: Throw an error when a value is passed that contains more than 10 values | ||
return records.filter( | ||
record => | ||
record && | ||
record[key] && | ||
Array.isArray(record[key]) && | ||
value && | ||
Array.isArray(value) && | ||
record[key].some(v => value.includes(v)), | ||
); | ||
} |
export const includeIdsInData: boolean; | ||
export const mutable: boolean; | ||
export const simulateQueryFilters: boolean; |
module.exports = { | ||
includeIdsInData: false, | ||
mutable: false, | ||
simulateQueryFilters: false, | ||
}; |
@@ -14,5 +14,7 @@ const buildQuerySnapShot = require('./helpers/buildQuerySnapShot'); | ||
class Query { | ||
constructor(collectionName, firestore) { | ||
constructor(collectionName, firestore, isGroupQuery = false) { | ||
this.collectionName = collectionName; | ||
this.firestore = firestore; | ||
this.filters = []; | ||
this.isGroupQuery = isGroupQuery; | ||
} | ||
@@ -22,32 +24,72 @@ | ||
mockGet(...arguments); | ||
// Use DFS to find all records in collections that match collectionName | ||
return Promise.resolve(this._get()); | ||
} | ||
_get() { | ||
// Simulate collectionGroup query | ||
// Get Firestore collections whose name match `this.collectionName`; return their documents | ||
const requestedRecords = []; | ||
const st = [this.firestore.database]; | ||
// At each collection list node, get collection in collection list whose id | ||
// matches this.collectionName | ||
while (st.length > 0) { | ||
const subcollections = st.pop(); | ||
const documents = subcollections[this.collectionName]; | ||
if (documents && Array.isArray(documents)) { | ||
requestedRecords.push(...documents); | ||
} | ||
const queue = [ | ||
{ | ||
lastParent: '', | ||
collections: this.firestore.database, | ||
}, | ||
]; | ||
// For each collection in subcollections, get each document's _collections array | ||
// and push onto st. | ||
Object.values(subcollections).forEach(collection => { | ||
const documents = collection.filter(d => !!d._collections); | ||
st.push(...documents.map(d => d._collections)); | ||
while (queue.length > 0) { | ||
// Get a collection | ||
const { lastParent, collections } = queue.shift(); | ||
Object.entries(collections).forEach(([collectionPath, docs]) => { | ||
const prefix = lastParent ? `${lastParent}/` : ''; | ||
const newLastParent = `${prefix}${collectionPath}`; | ||
const lastPathComponent = collectionPath.split('/').pop(); | ||
// If this is a matching collection, grep its documents | ||
if (lastPathComponent === this.collectionName) { | ||
const docHashes = docs.map(doc => { | ||
// Fetch the document from the mock db | ||
const path = `${newLastParent}/${doc.id}`; | ||
return { | ||
...doc, | ||
_ref: this.firestore._doc(path), | ||
}; | ||
}); | ||
requestedRecords.push(...docHashes); | ||
} | ||
// Enqueue adjacent collections for next run | ||
docs.forEach(doc => { | ||
if (doc._collections) { | ||
queue.push({ | ||
lastParent: `${prefix}${collectionPath}/${doc.id}`, | ||
collections: doc._collections, | ||
}); | ||
} | ||
}); | ||
}); | ||
} | ||
// Make sure we have a 'good enough' document reference | ||
requestedRecords.forEach(rec => { | ||
rec._ref = this.firestore.doc('database/'.concat(rec.id)); | ||
}); | ||
return Promise.resolve(buildQuerySnapShot(requestedRecords)); | ||
// Return the requested documents | ||
const isFilteringEnabled = this.firestore.options.simulateQueryFilters; | ||
return buildQuerySnapShot(requestedRecords, isFilteringEnabled ? this.filters : undefined); | ||
} | ||
where() { | ||
return mockWhere(...arguments) || this; | ||
where(key, comp, value) { | ||
const result = mockWhere(...arguments); | ||
if (result) { | ||
return result; | ||
} | ||
// Firestore has been tested to throw an error at this point when trying to compare null as a quantity | ||
if (value === null && !['==', '!='].includes(comp)) { | ||
throw new Error( | ||
`FakeFirebaseError: Invalid query. Null only supports '==' and '!=' comparisons.`, | ||
); | ||
} | ||
this.filters.push({ key, comp, value }); | ||
return result || this; | ||
} | ||
@@ -83,7 +125,9 @@ | ||
try { | ||
this.get().then(result => { | ||
callback(result); | ||
}); | ||
callback(this._get()); | ||
} catch (e) { | ||
errorCallback(e); | ||
if (errorCallback) { | ||
errorCallback(e); | ||
} else { | ||
throw e; | ||
} | ||
} | ||
@@ -90,0 +134,0 @@ |
@@ -20,3 +20,3 @@ const mockGetAll = jest.fn(); | ||
mockGetTransaction(...arguments); | ||
return ref.get(); | ||
return Promise.resolve(ref._get()); | ||
} | ||
@@ -23,0 +23,0 @@ |
{ | ||
"name": "firestore-jest-mock", | ||
"version": "0.13.0", | ||
"version": "0.14.0", | ||
"description": "Jest helper for mocking Google Cloud Firestore", | ||
@@ -5,0 +5,0 @@ "author": "", |
@@ -32,2 +32,3 @@ # Mock Firestore | ||
- [mutable](#mutable) | ||
- [simulateQueryFilters](#simulateQueryFilters) | ||
- [Functions you can test](#functions-you-can-test) | ||
@@ -88,4 +89,10 @@ - [Firestore](#firestore) | ||
This will populate a fake database with a `users` and `posts` collection. | ||
If you are using TypeScript, you can import `mockFirebase` using ES module syntax: | ||
```TypeScript | ||
import { mockFirebase } from 'firestore-jest-mock'; | ||
``` | ||
This will populate a fake database with a `users` and `posts` collection. This database is read-only by default, meaning that any Firestore write calls will not actually persist across invocations. | ||
Now you can write queries or requests for data just as you would with Firestore: | ||
@@ -112,2 +119,10 @@ | ||
In TypeScript, you would import `mockCollection` using ES module syntax: | ||
```TypeScript | ||
import { mockCollection } from 'firestore-jest-mock/mocks/firestore'; | ||
``` | ||
The other mock functions may be imported similarly. | ||
### `@google-cloud/firestore` compatibility | ||
@@ -151,3 +166,3 @@ | ||
A common case in Firestore is to store data in document [subcollections](https://firebase.google.com/docs/firestore/manage-data/structure-data#subcollections). You can model these in firestore-jest-mock like so: | ||
A common Firestore use case is to store data in document [subcollections](https://firebase.google.com/docs/firestore/manage-data/structure-data#subcollections). You can model these with firestore-jest-mock like so: | ||
@@ -182,3 +197,3 @@ ```js | ||
Similar to how the `id` key models a document object, the `_collections` key models a subcollection. You model each subcollection key in the same way that `database` is modeled above: an object keyed by collection IDs and populated with document arrays. | ||
Similar to how the `id` key defines a document object to firestore-jest-mock, the `_collections` key defines a subcollection. You model each subcollection structure in the same way that `database` is modeled above: an object keyed by collection IDs and populated with document arrays. | ||
@@ -212,3 +227,4 @@ This lets you model and validate more complex document access: | ||
The job of the this library is not to test Firestore, but to allow you to test your code without hitting firebase. | ||
The job of the this library is not to test Firestore, but to allow you to test your code without hitting Firebase servers or booting a local emulator. Since this package simulates most of the Firestore interface in plain JavaScript, unit tests can be quick and easy both to write and to execute. | ||
Take this example: | ||
@@ -313,2 +329,3 @@ | ||
mutable: true, | ||
simulateQueryFilters: true, | ||
}; | ||
@@ -319,3 +336,3 @@ | ||
#### includeIdsInData | ||
#### `includeIdsInData` | ||
@@ -326,3 +343,3 @@ By default, id's are not returned with the document's data. | ||
#### mutable | ||
#### `mutable` | ||
@@ -333,3 +350,3 @@ _Warning: Thar be dragons_ | ||
This means it doesn't update, even when you call things like `set` or `add`, as the result isn't typically important for your tests. | ||
If you need your tests to update the mock database, you can set `mutable` to true when calling `mockFirebase`. | ||
If you need your tests to update the mock database, you can set `mutable` to `true` when calling `mockFirebase`. | ||
Calling `.set()` on a document or collection would update the mock database you created. | ||
@@ -340,4 +357,10 @@ This can make your tests less predictable, as they may need to be run in the same order. | ||
_Note: not all APIs that update the database are supported yet. PR's welcome!_ | ||
_Note: not all APIs that update the database are supported yet. PRs welcome!_ | ||
#### `simulateQueryFilters` | ||
By default, query filters (read: `where` clauses) pass through all mock Firestore data without applying the requested filters. | ||
If you need your tests to perform `where` queries on mock database data, you can set `simulateQueryFilters` to `true` when calling `mockFirebase`. | ||
### Functions you can test | ||
@@ -344,0 +367,0 @@ |
@@ -12,3 +12,2 @@ { | ||
"alwaysStrict": true, | ||
"esModuleInterop": true, | ||
"forceConsistentCasingInFileNames": true, | ||
@@ -15,0 +14,0 @@ "declaration": true, |
192380
3597
445