From 8d665542cc8d780784e145333c6dff9b58a9bb12 Mon Sep 17 00:00:00 2001 From: Aleck Greenham Date: Sat, 6 May 2017 14:33:55 +0100 Subject: [PATCH] [Database] Standardise error messages and add context support for Reference.on() - Make error messages raised by Reference.on() same as Web API - Add support for context argument to Reference.on() - Add tests for Reference.on() - Add JSDoc comments for Reference.on() and some other minor methods --- lib/modules/base.js | 6 + lib/modules/database/reference.js | 254 ++++++++++-- lib/modules/database/snapshot.js | 1 + tests/src/tests/database/ref/index.js | 7 +- tests/src/tests/database/ref/on/onTests.js | 52 +++ .../src/tests/database/ref/on/onValueTests.js | 362 ++++++++++++++++++ tests/src/tests/database/ref/onTests.js | 125 ------ 7 files changed, 649 insertions(+), 158 deletions(-) create mode 100644 tests/src/tests/database/ref/on/onTests.js create mode 100644 tests/src/tests/database/ref/on/onValueTests.js delete mode 100644 tests/src/tests/database/ref/onTests.js diff --git a/lib/modules/base.js b/lib/modules/base.js index 5021bf01..8a44b896 100644 --- a/lib/modules/base.js +++ b/lib/modules/base.js @@ -76,6 +76,12 @@ export class ReferenceBase extends Base { this.path = path || '/'; } + /** + * The last part of a Reference's path (after the last '/') + * The key of a root Reference is null. + * @type {String} + * {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#key} + */ get key(): string|null { return this.path === '/' ? null : this.path.substring(this.path.lastIndexOf('/') + 1); } diff --git a/lib/modules/database/reference.js b/lib/modules/database/reference.js index 67f88c2d..1d8662e9 100644 --- a/lib/modules/database/reference.js +++ b/lib/modules/database/reference.js @@ -14,8 +14,44 @@ const FirebaseDatabase = NativeModules.RNFirebaseDatabase; let refId = 1; /** + * Enum for event types + * @readonly + * @enum {String} + */ +const ReferenceEventTypes = { + value: 'value', + child_added: 'child_added', + child_removed: 'child_removed', + child_changed: 'child_changed', + child_moved: 'child_moved', +}; + +/** + * @typedef {String} ReferenceLocation - Path to location in the database, relative + * to the root reference. Consists of a path where segments are separated by a + * forward slash (/) and ends in a ReferenceKey - except the root location, which + * has no ReferenceKey. + * + * @example + * // root reference location: '/' + * // non-root reference: '/path/to/referenceKey' + */ + +/** + * @typedef {String} ReferenceKey - Identifier for each location that is unique to that + * location, within the scope of its parent. The last part of a ReferenceLocation. + */ + +/** + * Represents a specific location in your Database that can be used for + * reading or writing data. + * + * You can reference the root using firebase.database().ref() or a child location + * by calling firebase.database().ref("child/path"). + * * @link https://firebase.google.com/docs/reference/js/firebase.database.Reference * @class Reference + * @extends ReferenceBase */ export default class Reference extends ReferenceBase { @@ -99,23 +135,152 @@ export default class Reference extends ReferenceBase { } /** + * Called once with the initial data at the specified location and then once each + * time the data changes. * - * @param eventName - * @param successCallback - * @param failureCallback - * @param context TODO - * @returns {*} + * @callback onValueCallback + * @param {!DataSnapshot} dataSnapshot - Snapshot representing data at the location + * specified by the current ref. It won't trigger (.val() won't return a value) + * until the entire contents have been synchronized. If location has no data, .val() + * will return null. */ - on(eventName: string, successCallback: () => any, failureCallback: () => any) { - if (!isFunction(successCallback)) throw new Error('The specified callback must be a function'); - if (failureCallback && !isFunction(failureCallback)) throw new Error('The specified error callback must be a function'); - this.log.debug('adding reference.on', this.refId, eventName); + + /** + * Called once for each initial child at the specified location and then again + * every time a new child is added. + * + * @callback onChildAddedCallback + * @param {!DataSnapshot} dataSnapshot - Snapshot reflecting the data for the + * relevant child. + * @param {?ReferenceKey} previousChildKey - For ordering purposes, the key + * of the previous sibling child by sort order, or null if it is the first child. + */ + + /** + * Called once every time a child is removed. + * + * A child will get removed when either: + * - remove() is explicitly called on a child or one of its ancestors + * - set(null) is called on that child or one of its ancestors + * - a child has all of its children removed + * - there is a query in effect which now filters out the child (because it's sort + * order changed or the max limit was hit) + * + * @callback onChildRemovedCallback + * @param {!DataSnapshot} dataSnapshot - Snapshot reflecting the old data for + * the child that was removed. + */ + + /** + * Called when a child (or any of its descendants) changes. + * + * A single child_changed event may represent multiple changes to the child. + * + * @callback onChildChangedCallback + * @param {!DataSnapshot} dataSnapshot - Snapshot reflecting new child contents. + * @param {?ReferenceKey} previousChildKey - For ordering purposes, the key + * of the previous sibling child by sort order, or null if it is the first child. + */ + + /** + * Called when a child's sort order changes, i.e. its position relative to its + * siblings changes. + * + * @callback onChildMovedCallback + * @param {!DataSnapshot} dataSnapshot - Snapshot reflecting the data of the moved + * child. + * @param {?ReferenceKey} previousChildKey - For ordering purposes, the key + * of the previous sibling child by sort order, or null if it is the first child. + */ + + /** + * @typedef (onValueCallback|onChildAddedCallback|onChildRemovedCallback|onChildChangedCallback|onChildMovedCallback) ReferenceEventCallback + */ + + /** + * Called if the event subscription is cancelled because the client does + * not have permission to read this data (or has lost the permission to do so). + * + * @callback onFailureCallback + * @param {Error} error - Object indicating why the failure occurred + */ + + /** + * Binds callback handlers to when data changes at the current ref's location. + * The primary method of reading data from a Database. + * + * Callbacks can be unbound using {@link off}. + * + * Event Types: + * + * - value: {@link onValueCallback}. + * - child_added: {@link onChildAddedCallback} + * - child_removed: {@link onChildRemovedCallback} + * - child_changed: {@link onChildChangedCallback} + * - child_moved: {@link onChildMovedCallback} + * + * @param {ReferenceEventType} eventType - Type of event to attach a callback for. + * @param {ReferenceEventCallback} successCallback - Function that will be called + * when the event occurs with the new data. + * @param {onFailureCallback=} failureCallbackOrContext - Optional callback that is called + * if the event subscription fails. {@link onFailureCallback} + * @param {*=} context - Optional object to bind the callbacks to when calling them. + * @returns {ReferenceEventCallback} callback function, unmodified (unbound), for + * convenience if you want to pass an inline function to on() and store it later for + * removing using off(). + * + * {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#on} + */ + on(eventType: string, successCallback: () => any, failureCallbackOrContext: () => any, context: any) { + if (!eventType) throw new Error('Error: Query on failed: Was called with 0 arguments. Expects at least 2'); + if (!ReferenceEventTypes[eventType]) throw new Error('Query.on failed: First argument must be a valid event type: "value", "child_added", "child_removed", "child_changed", or "child_moved".'); + if (!successCallback) throw new Error('Query.on failed: Was called with 1 argument. Expects at least 2.'); + if (!isFunction(successCallback)) throw new Error('Query.on failed: Second argument must be a valid function.'); + if (arguments.length > 2 && !failureCallbackOrContext) throw new Error('Query.on failed: third argument must either be a cancel callback or a context object.'); + + this.log.debug('adding reference.on', this.refId, eventType); + + let _failureCallback; + let _context; + + if (context) { + _context = context; + _failureCallback = failureCallbackOrContext; + } else { + if (isFunction(failureCallbackOrContext)) { + _failureCallback = failureCallbackOrContext; + } else { + _context = failureCallbackOrContext; + } + } + + if (_failureCallback) { + _failureCallback = (error) => { + if (error.message.startsWith('FirebaseError: permission_denied')) { + + error.message = `permission_denied at /${this.path}: Client doesn\'t have permission to access the desired data.` + } + + failureCallbackOrContext(error); + + } + } + + let _successCallback; + + if (_context) { + _successCallback = successCallback.bind(_context); + } else { + _successCallback = successCallback; + } + const listener = { listenerId: Object.keys(this.listeners).length + 1, - eventName, - successCallback, - failureCallback, + eventName: eventType, + successCallback: _successCallback, + failureCallback: _failureCallback, }; + this.listeners[listener.listenerId] = listener; this.database.on(this, listener); return successCallback; @@ -144,29 +309,49 @@ export default class Reference extends ReferenceBase { } /** + * Detaches a callback attached with on(). * - * @param eventName - * @param origCB - * @returns {*} + * Calling off() on a parent listener will not automatically remove listeners + * registered on child nodes. + * + * If on() was called multiple times with the same eventType off() must be + * called multiple times to completely remove it. + * + * If a callback is not specified, all callbacks for the specified eventType + * will be removed. If no eventType or callback is specified, all callbacks + * for the Reference will be removed. + * + * If a context is specified, it too is used as a filter parameter: a callback + * will only be detached if, when it was attached with on(), the same event type, + * callback function and context were provided. + * + * If no callbacks matching the parameters provided are found, no callbacks are + * detached. + * + * @param {('value'|'child_added'|'child_changed'|'child_removed'|'child_moved')=} eventType - Type of event to detach callback for. + * @param {Function=} originalCallback - Original callback passed to on() + * @param {*=} context - The context passed to on() when the callback was bound + * + * {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#off} */ - off(eventName?: string = '', origCB?: () => any) { - this.log.debug('ref.off(): ', this.refId, eventName); + off(eventType?: string = '', originalCallback?: () => any) { + this.log.debug('ref.off(): ', this.refId, eventType); // $FlowFixMe const listeners: Array = Object.values(this.listeners); let listenersToRemove; - if (eventName && origCB) { + if (eventType && originalCallback) { listenersToRemove = listeners.filter((listener) => { - return listener.eventName === eventName && listener.successCallback === origCB; + return listener.eventName === eventType && listener.successCallback === originalCallback; }); // Only remove a single listener as per the web spec if (listenersToRemove.length > 1) listenersToRemove = [listenersToRemove[0]]; - } else if (eventName) { + } else if (eventType) { listenersToRemove = listeners.filter((listener) => { - return listener.eventName === eventName; + return listener.eventName === eventType; }); - } else if (origCB) { + } else if (originalCallback) { listenersToRemove = listeners.filter((listener) => { - return listener.successCallback === origCB; + return listener.successCallback === originalCallback; }); } else { listenersToRemove = listeners; @@ -349,9 +534,12 @@ export default class Reference extends ReferenceBase { } /** - * Get a specified child - * @param path - * @returns {Reference} + * Creates a Reference to a child of the current Reference, using a relative path. + * No validation is performed on the path to ensure it has a valid format. + * @param {String} path relative to current ref's location + * @returns {!Reference} A new Reference to the path provided, relative to the current + * Reference + * {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#child} */ child(path: string) { return new Reference(this.database, `${this.path}/${path}`); @@ -370,6 +558,7 @@ export default class Reference extends ReferenceBase { * same instance of firebase.app.App - multiple firebase apps not currently supported. * @param {Reference} otherRef - Other reference to compare to this one * @return {Boolean} Whether otherReference is equal to this one + * * {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#isEqual} */ isEqual(otherRef: Reference): boolean { @@ -381,8 +570,10 @@ export default class Reference extends ReferenceBase { */ /** - * Returns the parent ref of the current ref i.e. a ref of /foo/bar would return a new ref to '/foo' - * @returns {*} + * The parent location of a Reference, or null for the root Reference. + * @type {Reference} + * + * {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#parent} */ get parent(): Reference|null { if (this.path === '/') return null; @@ -392,6 +583,7 @@ export default class Reference extends ReferenceBase { /** * A reference to itself * @type {!Reference} + * * {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#ref} */ get ref(): Reference { @@ -399,8 +591,10 @@ export default class Reference extends ReferenceBase { } /** - * Returns a ref to the root of db - '/' - * @returns {Reference} + * Reference to the root of the database: '/' + * @type {!Reference} + * + * {@link https://firebase.google.com/docs/reference/js/firebase.database.Reference#root} */ get root(): Reference { return new Reference(this.database, '/'); diff --git a/lib/modules/database/snapshot.js b/lib/modules/database/snapshot.js index 97c28546..9abb6b01 100644 --- a/lib/modules/database/snapshot.js +++ b/lib/modules/database/snapshot.js @@ -5,6 +5,7 @@ import Reference from './reference.js'; import { isObject, deepGet, deepExists } from './../../utils'; /** + * @class DataSnapshot * @link https://firebase.google.com/docs/reference/js/firebase.database.DataSnapshot */ export default class Snapshot { diff --git a/tests/src/tests/database/ref/index.js b/tests/src/tests/database/ref/index.js index 15b7424d..3e4707db 100644 --- a/tests/src/tests/database/ref/index.js +++ b/tests/src/tests/database/ref/index.js @@ -1,4 +1,5 @@ -import onTests from './onTests'; +import onTests from './on/onTests'; +import onValueTests from './on/onValueTests'; import offTests from './offTests'; import onceTests from './onceTests'; import setTests from './setTests'; @@ -19,8 +20,8 @@ import DatabaseContents from '../../support/DatabaseContents'; const testGroups = [ factoryTests, keyTests, parentTests, childTests, rootTests, - pushTests, onTests, offTests, onceTests, updateTests, removeTests, setTests, - transactionTests, queryTests, refTests, isEqualTests, + pushTests, onTests, onValueTests, offTests, onceTests, updateTests, + removeTests, setTests, transactionTests, queryTests, refTests, isEqualTests, ]; function registerTestSuite(testSuite) { diff --git a/tests/src/tests/database/ref/on/onTests.js b/tests/src/tests/database/ref/on/onTests.js new file mode 100644 index 00000000..32aeb754 --- /dev/null +++ b/tests/src/tests/database/ref/on/onTests.js @@ -0,0 +1,52 @@ +import 'should-sinon'; + +function onTests({ describe, it, firebase, context }) { + describe('ref().on()', () => { + // Observed Web API Behaviour + context('when no eventName is provided', () => { + it('then raises an error', () => { + const ref = firebase.native.database().ref('tests/types/number'); + + (() => { ref.on(); }).should.throw('Error: Query on failed: Was called with 0 arguments. Expects at least 2'); + }); + }); + + // Observed Web API Behaviour + context('when no callback function is provided', () => { + it('then raises an error', () => { + const ref = firebase.native.database().ref('tests/types/number'); + + (() => { ref.on('value'); }).should.throw('Query.on failed: Was called with 1 argument. Expects at least 2.'); + }); + }); + + // Observed Web API Behaviour + context('when an invalid eventName is provided', () => { + it('then raises an error', () => { + const ref = firebase.native.database().ref('tests/types/number'); + + (() => { ref.on('invalid', () => {}); }).should.throw('Query.on failed: First argument must be a valid event type: "value", "child_added", "child_removed", "child_changed", or "child_moved".'); + }); + }); + + // Observed Web API Behaviour + context('when an invalid success callback function is provided', () => { + it('then raises an error', () => { + const ref = firebase.native.database().ref('tests/types/number'); + + (() => { ref.on('value', 1); }).should.throw('Query.on failed: Second argument must be a valid function.'); + }); + }); + + // Observed Web API Behaviour + context('when an invalid failure callback function is provided', () => { + it('then raises an error', () => { + const ref = firebase.native.database().ref('tests/types/number'); + + (() => { ref.on('value', () => {}, null); }).should.throw('Query.on failed: third argument must either be a cancel callback or a context object.'); + }); + }); + }); +} + +export default onTests; diff --git a/tests/src/tests/database/ref/on/onValueTests.js b/tests/src/tests/database/ref/on/onValueTests.js new file mode 100644 index 00000000..af0f8ca2 --- /dev/null +++ b/tests/src/tests/database/ref/on/onValueTests.js @@ -0,0 +1,362 @@ +import sinon from 'sinon'; +import 'should-sinon'; +import Promise from 'bluebird'; + +import DatabaseContents from '../../../support/DatabaseContents'; + +function onTests({ describe, context, it, xit, firebase, tryCatch }) { + describe('ref().on(\'value\')', () => { + // Documented Web API Behaviour + it('returns the success callback', () => { + // Setup + + const successCallback = sinon.spy(); + const ref = firebase.native.database().ref('tests/types/array'); + + // Assertions + + ref.on('value', successCallback).should.eql(successCallback); + + // Tear down + + ref.off(); + }); + + // Documented Web API Behaviour + it('calls callback with null if there is no data at ref', async () => { + // Setup + const ref = firebase.native.database().ref('tests/types/invalid'); + + const callback = sinon.spy(); + + // Test + + await new Promise((resolve) => { + ref.on('value', (snapshot) => { + callback(snapshot.val()); + resolve(); + }); + }); + + // Assertions + + callback.should.be.calledWith(null); + + await ref.set(1); + + callback.should.be.calledWith(1); + + // Teardown + ref.off(); + await ref.set(null); + }); + + // Documented Web API Behaviour + it('calls callback with the initial data and then when value changes', () => { + return Promise.each(Object.keys(DatabaseContents.DEFAULT), async (dataRef) => { + // Setup + + const ref = firebase.native.database().ref(`tests/types/${dataRef}`); + const currentDataValue = DatabaseContents.DEFAULT[dataRef]; + + const callback = sinon.spy(); + + // Test + + await new Promise((resolve) => { + ref.on('value', (snapshot) => { + callback(snapshot.val()); + resolve(); + }); + }); + + callback.should.be.calledWith(currentDataValue); + + const newDataValue = DatabaseContents.NEW[dataRef]; + await ref.set(newDataValue); + + // Assertions + + callback.should.be.calledWith(newDataValue); + callback.should.be.calledTwice(); + + // Tear down + + ref.off(); + await ref.set(currentDataValue); + }); + }); + + it('calls callback when children of the ref change', async () => { + const ref = firebase.native.database().ref('tests/types/object'); + const currentDataValue = DatabaseContents.DEFAULT.object; + + const callback = sinon.spy(); + + // Test + + await new Promise((resolve) => { + ref.on('value', (snapshot) => { + callback(snapshot.val()); + resolve(); + }); + }); + + callback.should.be.calledWith(currentDataValue); + + const newDataValue = DatabaseContents.NEW.string; + const childRef = firebase.native.database().ref('tests/types/object/foo2'); + await childRef.set(newDataValue); + + // Assertions + + callback.should.be.calledWith({ + ...currentDataValue, + foo2: newDataValue, + }); + + callback.should.be.calledTwice(); + + // Tear down + + ref.off(); + await ref.set(currentDataValue); + }); + + /** + * Pending until behaviour of push is possibly fixed. Currently, causes + * an array to be converted to an object - don't think this is the intended + * behaviour + */ + xit('calls callback when child of the ref is added', async () => { + const ref = firebase.native.database().ref('tests/types/array'); + const currentDataValue = DatabaseContents.DEFAULT.array; + + const callback = sinon.spy(); + + // Test + + await new Promise((resolve) => { + ref.on('value', (snapshot) => { + callback(snapshot.val()); + resolve(); + }); + }); + + callback.should.be.calledWith(currentDataValue); + + await ref.push(37); + + // Assertions + callback.should.be.calledWith([ + ...currentDataValue, + 37, + ]); + + callback.should.be.calledTwice(); + + // Tear down + + ref.off(); + await ref.set(currentDataValue); + }); + + it('doesn\'t call callback when the ref is updated with the same value', async () => { + const ref = firebase.native.database().ref('tests/types/object'); + const currentDataValue = DatabaseContents.DEFAULT.object; + + const callback = sinon.spy(); + + // Test + + await new Promise((resolve) => { + ref.on('value', (snapshot) => { + callback(snapshot.val()); + resolve(); + }); + }); + + callback.should.be.calledWith(currentDataValue); + + await ref.set(currentDataValue); + + // Assertions + + callback.should.be.calledOnce(); // Callback is not called again + + // Tear down + + ref.off(); + }); + + // Documented Web API Behaviour + it('allows binding multiple callbacks to the same ref', () => { + return Promise.each(Object.keys(DatabaseContents.DEFAULT), async (dataRef) => { + // Setup + + const ref = firebase.native.database().ref(`tests/types/${dataRef}`); + const currentDataValue = DatabaseContents.DEFAULT[dataRef]; + + const callbackA = sinon.spy(); + const callbackB = sinon.spy(); + + // Test + + await new Promise((resolve) => { + ref.on('value', (snapshot) => { + callbackA(snapshot.val()); + resolve(); + }); + }); + + await new Promise((resolve) => { + ref.on('value', (snapshot) => { + callbackB(snapshot.val()); + resolve(); + }); + }); + + callbackA.should.be.calledWith(currentDataValue); + callbackA.should.be.calledOnce(); + + callbackB.should.be.calledWith(currentDataValue); + callbackB.should.be.calledOnce(); + + // Tear down + + ref.off(); + }); + }); + + context('when no failure callback is provided', () => { + it('then does not call the callback for a ref to un-permitted location', () => { + const invalidRef = firebase.native.database().ref('nope'); + + const callback = sinon.spy(); + + invalidRef.on('value', callback); + + /** + * As we are testing that a callback is "never" called, we just wait for + * a reasonable time before giving up. + */ + return new Promise((resolve) => { + setTimeout(() => { + callback.should.not.be.called(); + invalidRef.off(); + resolve(); + }, 1000); + }); + }); + + // Documented Web API Behaviour + it('then calls callback bound to the specified context with the initial data and then when value changes', () => { + return Promise.each(Object.keys(DatabaseContents.DEFAULT), async (dataRef) => { + // Setup + + const ref = firebase.native.database().ref(`tests/types/${dataRef}`); + const currentDataValue = DatabaseContents.DEFAULT[dataRef]; + + const context = { + callCount: 0, + }; + + // Test + + await new Promise((resolve) => { + ref.on('value', function(snapshot) { + this.value = snapshot.val(); + this.callCount += 1; + resolve(); + }, context); + }); + + context.value.should.eql(currentDataValue); + context.callCount.should.eql(1); + + const newDataValue = DatabaseContents.NEW[dataRef]; + await ref.set(newDataValue); + + // Assertions + + context.value.should.eql(newDataValue); + context.callCount.should.eql(2); + + // Tear down + + ref.off(); + await ref.set(currentDataValue); + }); + }); + + }); + + // Observed Web API Behaviour + context('when a failure callback is provided', () => { + it('then calls only the failure callback for a ref to un-permitted location', () => { + const invalidRef = firebase.native.database().ref('nope'); + + const callback = sinon.spy(); + + return new Promise((resolve, reject) => { + invalidRef.on('value', callback, tryCatch((error) => { + error.message.should.eql( + 'permission_denied at /nope: Client doesn\'t have permission to access the desired data.' + ); + error.name.should.eql('Error'); + + callback.should.not.be.called(); + + invalidRef.off(); + resolve(); + }, reject)); + }); + }); + + // Documented Web API Behaviour + it('then calls callback bound to the specified context with the initial data and then when value changes', () => { + return Promise.each(Object.keys(DatabaseContents.DEFAULT), async (dataRef) => { + // Setup + + const ref = firebase.native.database().ref(`tests/types/${dataRef}`); + const currentDataValue = DatabaseContents.DEFAULT[dataRef]; + + const context = { + callCount: 0, + }; + + const failureCallback = sinon.spy(); + + // Test + + await new Promise((resolve) => { + ref.on('value', function(snapshot) { + this.value = snapshot.val(); + this.callCount += 1; + resolve(); + }, failureCallback, context); + }); + + failureCallback.should.not.be.called(); + context.value.should.eql(currentDataValue); + context.callCount.should.eql(1); + + const newDataValue = DatabaseContents.NEW[dataRef]; + await ref.set(newDataValue); + + // Assertions + + context.value.should.eql(newDataValue); + context.callCount.should.eql(2); + + // Tear down + + ref.off(); + await ref.set(currentDataValue); + }); + }) + }); + }); +} + +export default onTests; diff --git a/tests/src/tests/database/ref/onTests.js b/tests/src/tests/database/ref/onTests.js deleted file mode 100644 index 8f01708b..00000000 --- a/tests/src/tests/database/ref/onTests.js +++ /dev/null @@ -1,125 +0,0 @@ -import sinon from 'sinon'; -import 'should-sinon'; -import Promise from 'bluebird'; - -import DatabaseContents from '../../support/DatabaseContents'; - -function onTests({ describe, it, firebase, tryCatch }) { - describe('ref().on()', () => { - it('calls callback when value changes', () => { - return Promise.each(Object.keys(DatabaseContents.DEFAULT), async (dataRef) => { - // Setup - - const ref = firebase.native.database().ref(`tests/types/${dataRef}`); - const currentDataValue = DatabaseContents.DEFAULT[dataRef]; - - const callback = sinon.spy(); - - // Test - - await new Promise((resolve) => { - ref.on('value', (snapshot) => { - callback(snapshot.val()); - resolve(); - }); - }); - - callback.should.be.calledWith(currentDataValue); - - const newDataValue = DatabaseContents.NEW[dataRef]; - await ref.set(newDataValue); - - // Assertions - - callback.should.be.calledWith(newDataValue); - - // Tear down - - ref.off(); - }); - }); - - it('allows binding multiple callbacks to the same ref', () => { - return Promise.each(Object.keys(DatabaseContents.DEFAULT), async (dataRef) => { - // Setup - - const ref = firebase.native.database().ref(`tests/types/${dataRef}`); - const currentDataValue = DatabaseContents.DEFAULT[dataRef]; - - const callbackA = sinon.spy(); - const callbackB = sinon.spy(); - - // Test - - await new Promise((resolve) => { - ref.on('value', (snapshot) => { - callbackA(snapshot.val()); - resolve(); - }); - }); - - await new Promise((resolve) => { - ref.on('value', (snapshot) => { - callbackB(snapshot.val()); - resolve(); - }); - }); - - callbackA.should.be.calledWith(currentDataValue); - callbackB.should.be.calledWith(currentDataValue); - - // Tear down - - ref.off(); - }); - }); - - it('calls callback with current values', () => { - return Promise.each(Object.keys(DatabaseContents.DEFAULT), (dataRef) => { - // Setup - - const dataTypeValue = DatabaseContents.DEFAULT[dataRef]; - const ref = firebase.native.database().ref(`tests/types/${dataRef}`); - - // Test - - return ref.on('value', (snapshot) => { - // Assertion - - snapshot.val().should.eql(dataTypeValue); - - // Tear down - - ref.off(); - }); - }); - }); - - it('errors if permission denied', () => { - return new Promise((resolve, reject) => { - const successCb = tryCatch(() => { - // Assertion - - reject(new Error('No permission denied error')); - }, reject); - - const failureCb = tryCatch((error) => { - // Assertion - - error.message.includes('permission_denied').should.be.true(); - resolve(); - }, reject); - - // Setup - - const invalidRef = firebase.native.database().ref('nope'); - - // Test - - invalidRef.on('value', successCb, failureCb); - }); - }); - }); -} - -export default onTests;