Skip to content

Commit

Permalink
Implement doc().set (#70)
Browse files Browse the repository at this point in the history
* simple implementation of set allows tests to mutate DB

* sandboxed unit tests to prevent mutability leaking between tests

* Implemented collection.add, enforced unique IDs

* ensure random Id id always string

* fixed build issue reported by babel project (unrecognized token)

* implement 'set' on batch.  Update to use 'mutable' option on DB creation

* Fixed batching & tests

* reverted no-longer-necessary test changes

* PR changes requested

* fixed missing collection bug
  • Loading branch information
FrozenKiwi authored May 11, 2021
1 parent 2d98d57 commit e5b0bc2
Show file tree
Hide file tree
Showing 6 changed files with 229 additions and 51 deletions.
2 changes: 1 addition & 1 deletion .vscode/settings.json
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
{
"typescript.tsdk": "node_modules/typescript/lib"
}
}
2 changes: 1 addition & 1 deletion __tests__/full-setup-google-cloud-firestore.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ describe('we can start a firestore application', () => {
})
.then(function(docRef) {
expect(mockAdd).toHaveBeenCalled();
expect(docRef).toHaveProperty('id', 'abc123');
expect(docRef).toHaveProperty('id');
});
});

Expand Down
4 changes: 2 additions & 2 deletions __tests__/full-setup.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -84,7 +84,7 @@ describe('we can start a firebase application', () => {
})
.then(function(docRef) {
expect(mockAdd).toHaveBeenCalled();
expect(docRef).toHaveProperty('id', 'abc123');
expect(docRef).toHaveProperty('id');
});
});

Expand Down Expand Up @@ -332,7 +332,7 @@ describe('we can start a firebase application', () => {

const record = await recordDoc.get();
expect(mockGet).toHaveBeenCalled();
expect(record).toHaveProperty('id', 'abc123');
expect(record).toHaveProperty('id');
expect(record.data).toBeInstanceOf(Function);
});

Expand Down
114 changes: 114 additions & 0 deletions __tests__/mock-firestore-mutable.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
const { FakeFirestore } = require('firestore-jest-mock');
const { mockCollection, mockDoc } = require('firestore-jest-mock/mocks/firestore');

describe('database mutations', () => {
beforeEach(() => {
jest.resetModules();
jest.clearAllMocks();
});

// db is a fn, instead a shared variable to enforce sandboxing data on each test.
const db = () =>
new FakeFirestore(
{
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' },
],
},
},
],
},
{ mutable: true },
);

test('it can set simple record data', async () => {
const mdb = db();
await mdb
.collection('animals')
.doc('fantasy')
.collection('dragons')
.doc('whisperingDeath')
.set({
age: 15,
food: 'omnivore',
special: 'tunneling',
});
expect(mockCollection).toHaveBeenCalledWith('dragons');
expect(mockDoc).toHaveBeenCalledWith('whisperingDeath');

const doc = await mdb.doc('animals/fantasy/dragons/whisperingDeath').get();
expect(doc.exists).toBe(true);
expect(doc.id).toBe('whisperingDeath');
});

test('it correctly merges data on update', async () => {
const mdb = db();
const homer = mdb.collection('characters').doc('homer');
await homer.set({ occupation: 'Astronaut' }, { merge: true });
const doc = await homer.get();
expect(doc.data().name).toEqual('Homer');
expect(doc.data().occupation).toEqual('Astronaut');
});

test('it correctly overwrites data on set', async () => {
const mdb = db();
const homer = mdb.collection('characters').doc('homer');
await homer.set({ occupation: 'Astronaut' });
const doc = await homer.get();
expect(doc.data().name).toBeUndefined();
expect(doc.data().occupation).toEqual('Astronaut');
});

test('it can batch appropriately', async () => {
const mdb = db();
const homer = mdb.collection('characters').doc('homer');
const krusty = mdb.collection('characters').doc('krusty');
await mdb
.batch()
.update(homer, { drink: 'duff' })
.set(krusty, { causeOfDeath: 'Simian homicide' })
.commit();

const homerData = (await homer.get()).data();
expect(homerData.name).toEqual('Homer');
expect(homerData.drink).toEqual('duff');
const krustyData = (await krusty.get()).data();
expect(krustyData.name).toBeUndefined();
expect(krustyData.causeOfDeath).toEqual('Simian homicide');
});

test('it can add to collection', async () => {
const col = db().collection('characters');
const newDoc1 = await col.add({
name: 'Lisa',
occupation: 'President-in-waiting',
address: { street: '742 Evergreen Terrace' },
});

const test = await newDoc1.get();
expect(test.exists).toBe(true);

const newDoc2 = await col.add({
name: 'Lisa',
occupation: 'President-in-waiting',
address: { street: '742 Evergreen Terrace' },
});
expect(newDoc2.id).not.toEqual(newDoc1.id);
});
});
7 changes: 4 additions & 3 deletions __tests__/mock-firestore.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -283,10 +283,11 @@ describe('Queries', () => {

test('New documents with random ID', async () => {
expect.assertions(1);
// As per docs, should have 'random' ID, but we'll use our usual 'abc123' for now.
// 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 newDoc = db.collection('foo').doc();
expect(newDoc.path).toBe('database/foo/abc123');
const col = db.collection('characters');
const newDoc = col.doc();
const otherIds = col.records().map(doc => doc.id);
expect(otherIds).not.toContainEqual(newDoc.id);
});
});
151 changes: 107 additions & 44 deletions mocks/firestore.js
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const transaction = require('./transaction');
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 = stubbedDatabase;
Expand All @@ -51,16 +53,19 @@ class FakeFirestore {
batch() {
mockBatch(...arguments);
return {
_ref: this,
delete() {
mockBatchDelete(...arguments);
return this;
},
set() {
set(doc, data, setOptions = {}) {
mockBatchSet(...arguments);
this._ref._updateData(doc.path, data, setOptions.merge);
return this;
},
update() {
update(doc, data) {
mockBatchUpdate(...arguments);
this._ref._updateData(doc.path, data, true);
return this;
},
commit() {
Expand Down Expand Up @@ -116,6 +121,52 @@ class FakeFirestore {
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('/')
.slice(1);
// Must be document-level, so even-numbered elements
if (pathArray.length % 2) {
throw new Error('The path array must be document-level');
}

// 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,
};
}
}

FakeFirestore.Query = query.Query;
Expand Down Expand Up @@ -158,6 +209,7 @@ FakeFirestore.DocumentReference = class {
if (typeof arguments[0] === 'function') {
[callback, errorCallback] = arguments;
} else {
// eslint-disable-next-line
[options, callback, errorCallback] = arguments;
}

Expand All @@ -178,53 +230,21 @@ FakeFirestore.DocumentReference = class {

get() {
query.mocks.mockGet(...arguments);
// Ignore leading slash
const pathArray = this.path.replace(/^\/+/, '').split('/');

pathArray.shift(); // drop 'database'; it's always first
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 Promise.resolve({ exists: false, data: () => undefined, id: this.id });
}

for (let index = 0; index < pathArray.length; index += 2) {
const collectionId = pathArray[index];
const documentId = pathArray[index + 1];

if (!document || !document._collections) {
return Promise.resolve({ exists: false, data: () => undefined, id: this.id });
}
requestedRecords = document._collections[collectionId] || [];
if (requestedRecords.length === 0) {
return Promise.resolve({ exists: false, data: () => undefined, id: this.id });
}

document = requestedRecords.find(record => record.id === documentId);
if (!document) {
return Promise.resolve({ exists: false, data: () => undefined, id: this.id });
}

// +2 skips to next document
}

if (!!document || false) {
document._ref = this;
return Promise.resolve(buildDocFromHash(document));
}
return Promise.resolve({ exists: false, data: () => undefined, id: this.id, ref: this });
const data = this._get();
return Promise.resolve(data);
}

update(object) {
mockUpdate(...arguments);
if (this._get().exists) {
this.firestore._updateData(this.path, object, true);
}
return Promise.resolve(buildDocFromHash({ ...object, _ref: this }));
}

set(object) {
set(object, setOptions = {}) {
mockSet(...arguments);
this.firestore._updateData(this.path, object, setOptions.merge);
return Promise.resolve(buildDocFromHash({ ...object, _ref: this }));
}

Expand Down Expand Up @@ -256,6 +276,47 @@ FakeFirestore.DocumentReference = class {
return this.query.startAt(...arguments);
}

_get() {
// Ignore leading slash
const pathArray = this.path.replace(/^\/+/, '').split('/');

pathArray.shift(); // drop 'database'; it's always first
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 { exists: false, data: () => undefined, id: this.id };
}

for (let index = 0; index < pathArray.length; index += 2) {
const collectionId = pathArray[index];
const documentId = pathArray[index + 1];

if (!document || !document._collections) {
return { exists: false, data: () => undefined, id: this.id };
}
requestedRecords = document._collections[collectionId] || [];
if (requestedRecords.length === 0) {
return { exists: false, data: () => undefined, id: this.id };
}

document = requestedRecords.find(record => record.id === documentId);
if (!document) {
return { exists: false, data: () => undefined, id: this.id };
}

// +2 skips to next document
}

if (!!document || false) {
document._ref = this;
return buildDocFromHash(document);
}
return { exists: false, data: () => undefined, id: this.id, ref: this };
}

withConverter() {
query.mocks.mockWithConverter(...arguments);
return this;
Expand All @@ -281,12 +342,14 @@ FakeFirestore.CollectionReference = class extends FakeFirestore.Query {
}
}

add() {
add(object) {
mockAdd(...arguments);
return Promise.resolve(new FakeFirestore.DocumentReference('abc123', this));
const newDoc = new FakeFirestore.DocumentReference(_randomId(), this);
this.firestore._updateData(newDoc.path, object);
return Promise.resolve(newDoc);
}

doc(id = 'abc123') {
doc(id = _randomId()) {
mockDoc(id);
return new FakeFirestore.DocumentReference(id, this, this.firestore);
}
Expand Down

0 comments on commit e5b0bc2

Please sign in to comment.