├── .gitignore ├── .jshintrc ├── Gruntfile.js ├── README.md ├── firebase-as-array.js ├── firebase-as-array.min.js ├── package.json ├── src └── firebase-as-array.js └── test ├── lib └── MockFirebase.js └── test.js /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea -------------------------------------------------------------------------------- /.jshintrc: -------------------------------------------------------------------------------- 1 | { 2 | "bitwise" : true, 3 | "boss" : true, 4 | "browser" : true, 5 | "curly" : true, 6 | "devel" : true, 7 | "eqnull" : true, 8 | "globals" : { 9 | "angular" : false, 10 | "Firebase" : false, 11 | "FirebaseSimpleLogin" : false 12 | }, 13 | "globalstrict" : true, 14 | "indent" : 2, 15 | "latedef" : true, 16 | "maxlen" : 115, 17 | "noempty" : true, 18 | "nonstandard" : true, 19 | "undef" : true, 20 | "unused" : true, 21 | "trailing" : true 22 | } 23 | -------------------------------------------------------------------------------- /Gruntfile.js: -------------------------------------------------------------------------------- 1 | /* global module */ 2 | 3 | module.exports = function(grunt) { 4 | 'use strict'; 5 | 6 | grunt.initConfig({ 7 | pkg: grunt.file.readJSON('package.json'), 8 | banner: '/*! <%= pkg.title || pkg.name %> - v<%= pkg.version %> - ' + 9 | '<%= grunt.template.today("yyyy-mm-dd") %>\n' + 10 | '<%= pkg.homepage ? "* " + pkg.homepage + "\\n" : "" %>' + 11 | '* Copyright (c) <%= grunt.template.today("yyyy") %> Kato\n' + 12 | '* MIT LICENSE */\n\n', 13 | 14 | concat: { 15 | app: { 16 | options: { banner: '<%= banner %>' }, 17 | src: [ 18 | 'src/firebase-as-array.js' 19 | ], 20 | dest: 'firebase-as-array.js' 21 | } 22 | }, 23 | 24 | uglify: { 25 | options: { 26 | preserveComments: 'some' 27 | }, 28 | app: { 29 | files: { 30 | 'firebase-as-array.min.js': ['firebase-as-array.js'] 31 | } 32 | } 33 | }, 34 | 35 | watch: { 36 | build: { 37 | files: ['src/**/*.js', 'Gruntfile.js'], 38 | tasks: ['make'], 39 | options: { 40 | interrupt: true 41 | } 42 | }, 43 | test: { 44 | files: ['src/**/*.js', 'Gruntfile.js', 'test/**'], 45 | tasks: ['test'] 46 | } 47 | }, 48 | 49 | // Configure a mochaTest task 50 | mochaTest: { 51 | test: { 52 | options: { 53 | growl: true, 54 | timeout: 5000, 55 | reporter: 'spec' 56 | }, 57 | require: [ 58 | "chai" 59 | ], 60 | log: true, 61 | src: ['test/*.js'] 62 | } 63 | }, 64 | 65 | notify: { 66 | watch: { 67 | options: { 68 | title: 'Grunt Watch', 69 | message: 'Build Finished' 70 | } 71 | } 72 | } 73 | 74 | }); 75 | 76 | require('load-grunt-tasks')(grunt); 77 | 78 | grunt.loadNpmTasks('grunt-contrib-uglify'); 79 | grunt.loadNpmTasks('grunt-contrib-concat'); 80 | grunt.loadNpmTasks('grunt-contrib-watch'); 81 | grunt.loadNpmTasks('grunt-notify'); 82 | grunt.loadNpmTasks('grunt-mocha-test'); 83 | 84 | grunt.registerTask('make', ['concat', 'uglify']); 85 | grunt.registerTask('test', ['make', 'mochaTest']); 86 | 87 | grunt.registerTask('default', ['make', 'test']); 88 | }; 89 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Firebase.getAsArray 2 | 3 | **This uses an outdated version of the Firebase SDK and is no longer applicable.** 4 | 5 | A simple library to demonstrate how arrays can be synchronized to a real-time, distributed system like Firebase. 6 | 7 | This library demonstrates the following best practices for using arrays with collaborative, real-time data: 8 | 9 | - make the array read only (don't use splice, pop, etc; have setter methods) 10 | - when possible, refer to records using a uniqueId rather than array index 11 | - synchronize changes from the server directly into our array 12 | - push local changes to the server and letting them trickle back 13 | 14 | In other words, our array is essentialy one-directional. Changes come from the server into the array, we read them out, we push our local edits to the server, they trickle back into the array. 15 | 16 | Read more about synchronized arrays and this lib on the [Firebase Blog](https://www.firebase.com/blog/). 17 | 18 | ## Installation 19 | 20 | Download the firebase-as-array.min.js file and include it in your HTML: 21 | 22 | 23 | 27 | 28 | Or in your node.js project: 29 | 30 | var Firebase = require('firebase'); 31 | var getAsArray = require('./firebase-as-array.js'); 32 | 33 | var ref = new Firebase(URL); 34 | var list = getAsArray(ref); 35 | 36 | ## Usage 37 | 38 | var ref = new Firebase(URL); 39 | 40 | // create a synchronized array 41 | var list = getAsArray(ref); 42 | 43 | // add a new record 44 | var ref = list.$add({foo: 'bar'}); 45 | 46 | // remove record 47 | list.$remove( key ); 48 | 49 | // set priority on a record 50 | list.$move( key, newPriority ); 51 | 52 | // find position of a key in the list 53 | list.$indexOf( key ); 54 | 55 | // find key for a record at position 1 56 | list[1].$id; 57 | 58 | ## Limitations 59 | 60 | All the records stored in the array are objects. Primitive values get stored as { '.value': primitive } 61 | 62 | Does not support IE 8 and below by default. To support these, simply include polyfills for 63 | [Array.prototype.forEach](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Array/forEach#Polyfill) 64 | and [Function.prototype.bind](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Function/bind#Compatibility) in your code base. 65 | 66 | ## API 67 | 68 | ### getAsArray(ref[, eventCallback]) 69 | 70 | @param {Firebase} ref 71 | @param {Function} [callback] 72 | @returns {Array} 73 | 74 | Creates a new array and synchronizes it to the ref provided. 75 | 76 | ### $id 77 | 78 | The record ID. This is the unique URL key used to store the record in Firebase (the equivalent of firebaseRef.name()). 79 | 80 | ### $indexOf(key) 81 | 82 | @param {String} key the path name for a record to find in the array 83 | @returns {int} the index of the element in the array or -1 if not found 84 | 85 | A convenience method to find the array position of a given key. 86 | 87 | ### $add(data) 88 | 89 | @param data the data to be put in Firebase as a new child record 90 | @returns {Firebase} the ref to the newly created record 91 | 92 | Adds a record to Firebase and returns the reference. To obtain its id, use `ref.name()`, assuming `ref` is the variable assigned to the return value. 93 | 94 | ### $remove(key) 95 | 96 | @param {string} key a record id to be removed locally and remotely 97 | 98 | Removes a record locally and from Firebase 99 | 100 | ### $set(key, data) 101 | 102 | @param {string} key a record id to be replaced 103 | @param data what goes into it 104 | 105 | Replaces the value of a record locally and in Firebase 106 | 107 | ### $update(key, data) 108 | 109 | @param {string} key a record id to be updated 110 | @param {object} data some keys to be replaced 111 | 112 | Updates the value of a record locally, replacing any keys that are in `data` with the values provided and leaving the rest of the record alone. 113 | 114 | ### $move(key, newPriority) 115 | 116 | @param {string} key record id to be moved 117 | @param {string|int} newPriority the sort order to be applied 118 | 119 | Moves a record locally and in the remote data list. 120 | 121 | ## Development 122 | 123 | This lib is intended primarily to be an example. However, pull requests will be happily accepted. 124 | 125 | git clone git@github.com:yourname/Firebase.getAsArray 126 | npm install 127 | grunt 128 | grunt watch 129 | # make your fixes 130 | # verify tests are at 100% 131 | grunt make 132 | git commit -m "your changes" 133 | git push 134 | # create a pull request! 135 | 136 | ## Support 137 | 138 | Use the issue tracker if you have questions or problems. Since this is meant primarily as an example, do not expect prompt replies! 139 | -------------------------------------------------------------------------------- /firebase-as-array.js: -------------------------------------------------------------------------------- 1 | /*! Firebase.getAsArray - v0.1.0 - 2014-04-21 2 | * Copyright (c) 2014 Kato 3 | * MIT LICENSE */ 4 | 5 | (function(exports) { 6 | 7 | exports.getAsArray = function(ref, eventCallback) { 8 | return new ReadOnlySynchronizedArray(ref, eventCallback).getList(); 9 | }; 10 | 11 | function ReadOnlySynchronizedArray(ref, eventCallback) { 12 | this.list = []; 13 | this.subs = []; // used to track event listeners for dispose() 14 | this.ref = ref; 15 | this.eventCallback = eventCallback; 16 | this._wrapList(); 17 | this._initListeners(); 18 | } 19 | 20 | ReadOnlySynchronizedArray.prototype = { 21 | getList: function() { 22 | return this.list; 23 | }, 24 | 25 | add: function(data) { 26 | var key = this.ref.push().name(); 27 | var ref = this.ref.child(key); 28 | if( arguments.length > 0 ) { ref.set(parseForJson(data), this._handleErrors.bind(this, key)); } 29 | return ref; 30 | }, 31 | 32 | set: function(key, newValue) { 33 | this.ref.child(key).set(parseForJson(newValue), this._handleErrors.bind(this, key)); 34 | }, 35 | 36 | update: function(key, newValue) { 37 | this.ref.child(key).update(parseForJson(newValue), this._handleErrors.bind(this, key)); 38 | }, 39 | 40 | setPriority: function(key, newPriority) { 41 | this.ref.child(key).setPriority(newPriority); 42 | }, 43 | 44 | remove: function(key) { 45 | this.ref.child(key).remove(this._handleErrors.bind(null, key)); 46 | }, 47 | 48 | posByKey: function(key) { 49 | return findKeyPos(this.list, key); 50 | }, 51 | 52 | placeRecord: function(key, prevId) { 53 | if( prevId === null ) { 54 | return 0; 55 | } 56 | else { 57 | var i = this.posByKey(prevId); 58 | if( i === -1 ) { 59 | return this.list.length; 60 | } 61 | else { 62 | return i+1; 63 | } 64 | } 65 | }, 66 | 67 | getRecord: function(key) { 68 | var i = this.posByKey(key); 69 | if( i === -1 ) return null; 70 | return this.list[i]; 71 | }, 72 | 73 | dispose: function() { 74 | var ref = this.ref; 75 | this.subs.forEach(function(s) { 76 | ref.off(s[0], s[1]); 77 | }); 78 | this.subs = []; 79 | }, 80 | 81 | _serverAdd: function(snap, prevId) { 82 | var data = parseVal(snap.name(), snap.val()); 83 | this._moveTo(snap.name(), data, prevId); 84 | this._handleEvent('child_added', snap.name(), data); 85 | }, 86 | 87 | _serverRemove: function(snap) { 88 | var pos = this.posByKey(snap.name()); 89 | if( pos !== -1 ) { 90 | this.list.splice(pos, 1); 91 | this._handleEvent('child_removed', snap.name(), this.list[pos]); 92 | } 93 | }, 94 | 95 | _serverChange: function(snap) { 96 | var pos = this.posByKey(snap.name()); 97 | if( pos !== -1 ) { 98 | this.list[pos] = applyToBase(this.list[pos], parseVal(snap.name(), snap.val())); 99 | this._handleEvent('child_changed', snap.name(), this.list[pos]); 100 | } 101 | }, 102 | 103 | _serverMove: function(snap, prevId) { 104 | var id = snap.name(); 105 | var oldPos = this.posByKey(id); 106 | if( oldPos !== -1 ) { 107 | var data = this.list[oldPos]; 108 | this.list.splice(oldPos, 1); 109 | this._moveTo(id, data, prevId); 110 | this._handleEvent('child_moved', snap.name(), data); 111 | } 112 | }, 113 | 114 | _moveTo: function(id, data, prevId) { 115 | var pos = this.placeRecord(id, prevId); 116 | this.list.splice(pos, 0, data); 117 | }, 118 | 119 | _handleErrors: function(key, err) { 120 | if( err ) { 121 | this._handleEvent('error', null, key); 122 | console.error(err); 123 | } 124 | }, 125 | 126 | _handleEvent: function(eventType, recordId, data) { 127 | // console.log(eventType, recordId); 128 | this.eventCallback && this.eventCallback(eventType, recordId, data); 129 | }, 130 | 131 | _wrapList: function() { 132 | this.list.$indexOf = this.posByKey.bind(this); 133 | this.list.$add = this.add.bind(this); 134 | this.list.$remove = this.remove.bind(this); 135 | this.list.$set = this.set.bind(this); 136 | this.list.$update = this.update.bind(this); 137 | this.list.$move = this.setPriority.bind(this); 138 | this.list.$rawData = function(key) { return parseForJson(this.getRecord(key)) }.bind(this); 139 | this.list.$off = this.dispose.bind(this); 140 | }, 141 | 142 | _initListeners: function() { 143 | this._monit('child_added', this._serverAdd); 144 | this._monit('child_removed', this._serverRemove); 145 | this._monit('child_changed', this._serverChange); 146 | this._monit('child_moved', this._serverMove); 147 | }, 148 | 149 | _monit: function(event, method) { 150 | this.subs.push([event, this.ref.on(event, method.bind(this))]); 151 | } 152 | }; 153 | 154 | function applyToBase(base, data) { 155 | // do not replace the reference to objects contained in the data 156 | // instead, just update their child values 157 | if( isObject(base) && isObject(data) ) { 158 | var key; 159 | for(key in base) { 160 | if( key !== '$id' && base.hasOwnProperty(key) && !data.hasOwnProperty(key) ) { 161 | delete base[key]; 162 | } 163 | } 164 | for(key in data) { 165 | if( data.hasOwnProperty(key) ) { 166 | base[key] = data[key]; 167 | } 168 | } 169 | return base; 170 | } 171 | else { 172 | return data; 173 | } 174 | } 175 | 176 | function isObject(x) { 177 | return typeof(x) === 'object' && x !== null; 178 | } 179 | 180 | function findKeyPos(list, key) { 181 | for(var i = 0, len = list.length; i < len; i++) { 182 | if( list[i].$id === key ) { 183 | return i; 184 | } 185 | } 186 | return -1; 187 | } 188 | 189 | function parseForJson(data) { 190 | if( data && typeof(data) === 'object' ) { 191 | delete data['$id']; 192 | if( data.hasOwnProperty('.value') ) { 193 | data = data['.value']; 194 | } 195 | } 196 | if( data === undefined ) { 197 | data = null; 198 | } 199 | return data; 200 | } 201 | 202 | function parseVal(id, data) { 203 | if( typeof(data) !== 'object' || !data ) { 204 | data = { '.value': data }; 205 | } 206 | data['$id'] = id; 207 | return data; 208 | } 209 | })(typeof(window)==='undefined'? exports : window.Firebase); -------------------------------------------------------------------------------- /firebase-as-array.min.js: -------------------------------------------------------------------------------- 1 | /*! Firebase.getAsArray - v0.1.0 - 2014-04-21 2 | * Copyright (c) 2014 Kato 3 | * MIT LICENSE */ 4 | !function(a){function b(a,b){this.list=[],this.subs=[],this.ref=a,this.eventCallback=b,this._wrapList(),this._initListeners()}function c(a,b){if(d(a)&&d(b)){var c;for(c in a)"$id"!==c&&a.hasOwnProperty(c)&&!b.hasOwnProperty(c)&&delete a[c];for(c in b)b.hasOwnProperty(c)&&(a[c]=b[c]);return a}return b}function d(a){return"object"==typeof a&&null!==a}function e(a,b){for(var c=0,d=a.length;d>c;c++)if(a[c].$id===b)return c;return-1}function f(a){return a&&"object"==typeof a&&(delete a.$id,a.hasOwnProperty(".value")&&(a=a[".value"])),void 0===a&&(a=null),a}function g(a,b){return"object"==typeof b&&b||(b={".value":b}),b.$id=a,b}a.getAsArray=function(a,c){return new b(a,c).getList()},b.prototype={getList:function(){return this.list},add:function(a){var b=this.ref.push().name(),c=this.ref.child(b);return arguments.length>0&&c.set(f(a),this._handleErrors.bind(this,b)),c},set:function(a,b){this.ref.child(a).set(f(b),this._handleErrors.bind(this,a))},update:function(a,b){this.ref.child(a).update(f(b),this._handleErrors.bind(this,a))},setPriority:function(a,b){this.ref.child(a).setPriority(b)},remove:function(a){this.ref.child(a).remove(this._handleErrors.bind(null,a))},posByKey:function(a){return e(this.list,a)},placeRecord:function(a,b){if(null===b)return 0;var c=this.posByKey(b);return-1===c?this.list.length:c+1},getRecord:function(a){var b=this.posByKey(a);return-1===b?null:this.list[b]},dispose:function(){var a=this.ref;this.subs.forEach(function(b){a.off(b[0],b[1])}),this.subs=[]},_serverAdd:function(a,b){var c=g(a.name(),a.val());this._moveTo(a.name(),c,b),this._handleEvent("child_added",a.name(),c)},_serverRemove:function(a){var b=this.posByKey(a.name());-1!==b&&(this.list.splice(b,1),this._handleEvent("child_removed",a.name(),this.list[b]))},_serverChange:function(a){var b=this.posByKey(a.name());-1!==b&&(this.list[b]=c(this.list[b],g(a.name(),a.val())),this._handleEvent("child_changed",a.name(),this.list[b]))},_serverMove:function(a,b){var c=a.name(),d=this.posByKey(c);if(-1!==d){var e=this.list[d];this.list.splice(d,1),this._moveTo(c,e,b),this._handleEvent("child_moved",a.name(),e)}},_moveTo:function(a,b,c){var d=this.placeRecord(a,c);this.list.splice(d,0,b)},_handleErrors:function(a,b){b&&(this._handleEvent("error",null,a),console.error(b))},_handleEvent:function(a,b,c){this.eventCallback&&this.eventCallback(a,b,c)},_wrapList:function(){this.list.$indexOf=this.posByKey.bind(this),this.list.$add=this.add.bind(this),this.list.$remove=this.remove.bind(this),this.list.$set=this.set.bind(this),this.list.$update=this.update.bind(this),this.list.$move=this.setPriority.bind(this),this.list.$rawData=function(a){return f(this.getRecord(a))}.bind(this),this.list.$off=this.dispose.bind(this)},_initListeners:function(){this._monit("child_added",this._serverAdd),this._monit("child_removed",this._serverRemove),this._monit("child_changed",this._serverChange),this._monit("child_moved",this._serverMove)},_monit:function(a,b){this.subs.push([a,this.ref.on(a,b.bind(this))])}}}("undefined"==typeof window?exports:window.Firebase); -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "Firebase.getAsArray", 3 | "version": "0.1.0", 4 | "description": "", 5 | "main": "firebase-as-array.js", 6 | "repository": { 7 | "type": "git", 8 | "url": "https://github.com/firebase/angularFire.git" 9 | }, 10 | "bugs": { 11 | "url": "https://github.com/firebase/angularFire/issues" 12 | }, 13 | "devDependencies": { 14 | "chai": "~1.9.1", 15 | "grunt": "~0.4.4", 16 | "grunt-contrib-concat": "^0.4.0", 17 | "grunt-contrib-jshint": "~0.6.2", 18 | "grunt-contrib-uglify": "~0.4.0", 19 | "grunt-contrib-watch": "~0.6.1", 20 | "grunt-mocha-test": "~0.10.2", 21 | "grunt-notify": "~0.2.20", 22 | "load-grunt-tasks": "~0.4.0", 23 | "lodash": "~2.4.1", 24 | "sinon": "~1.9.1", 25 | "sinon-chai": "~2.5.0" 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/firebase-as-array.js: -------------------------------------------------------------------------------- 1 | (function(exports) { 2 | 3 | exports.getAsArray = function(ref, eventCallback) { 4 | return new ReadOnlySynchronizedArray(ref, eventCallback).getList(); 5 | }; 6 | 7 | function ReadOnlySynchronizedArray(ref, eventCallback) { 8 | this.list = []; 9 | this.subs = []; // used to track event listeners for dispose() 10 | this.ref = ref; 11 | this.eventCallback = eventCallback; 12 | this._wrapList(); 13 | this._initListeners(); 14 | } 15 | 16 | ReadOnlySynchronizedArray.prototype = { 17 | getList: function() { 18 | return this.list; 19 | }, 20 | 21 | add: function(data) { 22 | var key = this.ref.push().name(); 23 | var ref = this.ref.child(key); 24 | if( arguments.length > 0 ) { ref.set(parseForJson(data), this._handleErrors.bind(this, key)); } 25 | return ref; 26 | }, 27 | 28 | set: function(key, newValue) { 29 | this.ref.child(key).set(parseForJson(newValue), this._handleErrors.bind(this, key)); 30 | }, 31 | 32 | update: function(key, newValue) { 33 | this.ref.child(key).update(parseForJson(newValue), this._handleErrors.bind(this, key)); 34 | }, 35 | 36 | setPriority: function(key, newPriority) { 37 | this.ref.child(key).setPriority(newPriority); 38 | }, 39 | 40 | remove: function(key) { 41 | this.ref.child(key).remove(this._handleErrors.bind(null, key)); 42 | }, 43 | 44 | posByKey: function(key) { 45 | return findKeyPos(this.list, key); 46 | }, 47 | 48 | placeRecord: function(key, prevId) { 49 | if( prevId === null ) { 50 | return 0; 51 | } 52 | else { 53 | var i = this.posByKey(prevId); 54 | if( i === -1 ) { 55 | return this.list.length; 56 | } 57 | else { 58 | return i+1; 59 | } 60 | } 61 | }, 62 | 63 | getRecord: function(key) { 64 | var i = this.posByKey(key); 65 | if( i === -1 ) return null; 66 | return this.list[i]; 67 | }, 68 | 69 | dispose: function() { 70 | var ref = this.ref; 71 | this.subs.forEach(function(s) { 72 | ref.off(s[0], s[1]); 73 | }); 74 | this.subs = []; 75 | }, 76 | 77 | _serverAdd: function(snap, prevId) { 78 | var data = parseVal(snap.name(), snap.val()); 79 | this._moveTo(snap.name(), data, prevId); 80 | this._handleEvent('child_added', snap.name(), data); 81 | }, 82 | 83 | _serverRemove: function(snap) { 84 | var pos = this.posByKey(snap.name()); 85 | if( pos !== -1 ) { 86 | this.list.splice(pos, 1); 87 | this._handleEvent('child_removed', snap.name(), this.list[pos]); 88 | } 89 | }, 90 | 91 | _serverChange: function(snap) { 92 | var pos = this.posByKey(snap.name()); 93 | if( pos !== -1 ) { 94 | this.list[pos] = applyToBase(this.list[pos], parseVal(snap.name(), snap.val())); 95 | this._handleEvent('child_changed', snap.name(), this.list[pos]); 96 | } 97 | }, 98 | 99 | _serverMove: function(snap, prevId) { 100 | var id = snap.name(); 101 | var oldPos = this.posByKey(id); 102 | if( oldPos !== -1 ) { 103 | var data = this.list[oldPos]; 104 | this.list.splice(oldPos, 1); 105 | this._moveTo(id, data, prevId); 106 | this._handleEvent('child_moved', snap.name(), data); 107 | } 108 | }, 109 | 110 | _moveTo: function(id, data, prevId) { 111 | var pos = this.placeRecord(id, prevId); 112 | this.list.splice(pos, 0, data); 113 | }, 114 | 115 | _handleErrors: function(key, err) { 116 | if( err ) { 117 | this._handleEvent('error', null, key); 118 | console.error(err); 119 | } 120 | }, 121 | 122 | _handleEvent: function(eventType, recordId, data) { 123 | // console.log(eventType, recordId); 124 | this.eventCallback && this.eventCallback(eventType, recordId, data); 125 | }, 126 | 127 | _wrapList: function() { 128 | this.list.$indexOf = this.posByKey.bind(this); 129 | this.list.$add = this.add.bind(this); 130 | this.list.$remove = this.remove.bind(this); 131 | this.list.$set = this.set.bind(this); 132 | this.list.$update = this.update.bind(this); 133 | this.list.$move = this.setPriority.bind(this); 134 | this.list.$rawData = function(key) { return parseForJson(this.getRecord(key)) }.bind(this); 135 | this.list.$off = this.dispose.bind(this); 136 | }, 137 | 138 | _initListeners: function() { 139 | this._monit('child_added', this._serverAdd); 140 | this._monit('child_removed', this._serverRemove); 141 | this._monit('child_changed', this._serverChange); 142 | this._monit('child_moved', this._serverMove); 143 | }, 144 | 145 | _monit: function(event, method) { 146 | this.subs.push([event, this.ref.on(event, method.bind(this))]); 147 | } 148 | }; 149 | 150 | function applyToBase(base, data) { 151 | // do not replace the reference to objects contained in the data 152 | // instead, just update their child values 153 | if( isObject(base) && isObject(data) ) { 154 | var key; 155 | for(key in base) { 156 | if( key !== '$id' && base.hasOwnProperty(key) && !data.hasOwnProperty(key) ) { 157 | delete base[key]; 158 | } 159 | } 160 | for(key in data) { 161 | if( data.hasOwnProperty(key) ) { 162 | base[key] = data[key]; 163 | } 164 | } 165 | return base; 166 | } 167 | else { 168 | return data; 169 | } 170 | } 171 | 172 | function isObject(x) { 173 | return typeof(x) === 'object' && x !== null; 174 | } 175 | 176 | function findKeyPos(list, key) { 177 | for(var i = 0, len = list.length; i < len; i++) { 178 | if( list[i].$id === key ) { 179 | return i; 180 | } 181 | } 182 | return -1; 183 | } 184 | 185 | function parseForJson(data) { 186 | if( data && typeof(data) === 'object' ) { 187 | delete data['$id']; 188 | if( data.hasOwnProperty('.value') ) { 189 | data = data['.value']; 190 | } 191 | } 192 | if( data === undefined ) { 193 | data = null; 194 | } 195 | return data; 196 | } 197 | 198 | function parseVal(id, data) { 199 | if( typeof(data) !== 'object' || !data ) { 200 | data = { '.value': data }; 201 | } 202 | data['$id'] = id; 203 | return data; 204 | } 205 | })(typeof(window)==='undefined'? exports : window.Firebase); -------------------------------------------------------------------------------- /test/lib/MockFirebase.js: -------------------------------------------------------------------------------- 1 | /** 2 | * MockFirebase: A Firebase stub/spy library for writing unit tests 3 | * https://github.com/katowulf/mockfirebase 4 | * @version 0.0.3 5 | */ 6 | (function(exports) { 7 | var DEBUG = false; 8 | var PUSH_COUNTER = 0; 9 | var _ = requireLib('lodash', '_'); 10 | var sinon = requireLib('sinon'); 11 | var _Firebase = exports.Firebase; 12 | var _FirebaseSimpleLogin = exports.FirebaseSimpleLogin; 13 | 14 | /** 15 | * A mock that simulates Firebase operations for use in unit tests. 16 | * 17 | * ## Setup 18 | * 19 | * // in windows 20 | * 21 | * 22 | * 23 | * 24 | * // in node.js 25 | * var Firebase = require('../lib/MockFirebase'); 26 | * 27 | * ## Usage Examples 28 | * 29 | * var fb = new Firebase('Mock://foo/bar'); 30 | * fb.on('value', function(snap) { 31 | * console.log(snap.val()); 32 | * }); 33 | * 34 | * // do something async or synchronously... 35 | * 36 | * // trigger callbacks and event listeners 37 | * fb.flush(); 38 | * 39 | * // spy on methods 40 | * expect(fb.on.called).toBe(true); 41 | * 42 | * ## Trigger events automagically instead of calling flush() 43 | * 44 | * var fb = new MockFirebase('Mock://hello/world'); 45 | * fb.autoFlush(1000); // triggers events after 1 second (asynchronous) 46 | * fb.autoFlush(); // triggers events immediately (synchronous) 47 | * 48 | * ## Simulating Errors 49 | * 50 | * var fb = new MockFirebase('Mock://fails/a/lot'); 51 | * fb.failNext('set', new Error('PERMISSION_DENIED'); // create an error to be invoked on the next set() op 52 | * fb.set({foo: bar}, function(err) { 53 | * // err.message === 'PERMISSION_DENIED' 54 | * }); 55 | * fb.flush(); 56 | * 57 | * @param {string} [currentPath] use a relative path here or a url, all .child() calls will append to this 58 | * @param {Object} [data] specify the data in this Firebase instance (defaults to MockFirebase.DEFAULT_DATA) 59 | * @param {MockFirebase} [parent] for internal use 60 | * @param {string} [name] for internal use 61 | * @constructor 62 | */ 63 | function MockFirebase(currentPath, data, parent, name) { 64 | // these are set whenever startAt(), limit() or endAt() get invoked 65 | this._queryProps = { limit: undefined, startAt: undefined, endAt: undefined }; 66 | 67 | // represents the fake url 68 | this.currentPath = currentPath || 'Mock://'; 69 | 70 | // do not modify this directly, use set() and flush(true) 71 | this.data = _.cloneDeep(arguments.length > 1? data||null : MockFirebase.DEFAULT_DATA); 72 | 73 | // see failNext() 74 | this.errs = {}; 75 | 76 | // used for setPriorty and moving records 77 | this.priority = null; 78 | 79 | // null for the root path 80 | this.myName = parent? name : extractName(currentPath); 81 | 82 | // see autoFlush() 83 | this.flushDelay = false; 84 | 85 | // stores the listeners for various event types 86 | this._events = { value: [], child_added: [], child_removed: [], child_changed: [], child_moved: [] }; 87 | 88 | // allows changes to be propagated between child/parent instances 89 | this.parent = parent||null; 90 | this.children = []; 91 | parent && parent.children.push(this); 92 | 93 | // stores the operations that have been queued until a flush() event is triggered 94 | this.ops = []; 95 | 96 | // turn all our public methods into spies so they can be monitored for calls and return values 97 | // see jasmine spies: https://github.com/pivotal/jasmine/wiki/Spies 98 | // the Firebase constructor can be spied on using spyOn(window, 'Firebase') from within the test unit 99 | for(var key in this) { 100 | if( !key.match(/^_/) && typeof(this[key]) === 'function' ) { 101 | sinon.spy(this, key); 102 | } 103 | } 104 | } 105 | 106 | MockFirebase.prototype = { 107 | /***************************************************** 108 | * Test Unit tools (not part of Firebase API) 109 | *****************************************************/ 110 | 111 | /** 112 | * Invoke all the operations that have been queued thus far. If a numeric delay is specified, this 113 | * occurs asynchronously. Otherwise, it is a synchronous event. 114 | * 115 | * This allows Firebase to be used in synchronous tests without waiting for async callbacks. It also 116 | * provides a rudimentary mechanism for simulating locally cached data (events are triggered 117 | * synchronously when you do on('value') or on('child_added') against locally cached data) 118 | * 119 | * If you call this multiple times with different delay values, you could invoke the events out 120 | * of order; make sure that is your intention. 121 | * 122 | * @param {boolean|int} [delay] 123 | * @returns {MockFirebase} 124 | */ 125 | flush: function(delay) { 126 | var self = this, list = self.ops; 127 | self.ops = []; 128 | if( _.isNumber(delay) ) { 129 | setTimeout(process, delay); 130 | } 131 | else { 132 | process(); 133 | } 134 | function process() { 135 | list.forEach(function(parts) { 136 | parts[0].apply(self, parts.slice(1)); 137 | }); 138 | 139 | self.children.forEach(function(c) { 140 | c.flush(); 141 | }); 142 | } 143 | return self; 144 | }, 145 | 146 | /** 147 | * Automatically trigger a flush event after each operation. If a numeric delay is specified, this is an 148 | * asynchronous event. If value is set to true, it is synchronous (flush is triggered immediately). Setting 149 | * this to false disables autoFlush 150 | * 151 | * @param {int|boolean} [delay] 152 | */ 153 | autoFlush: function(delay){ 154 | this.flushDelay = _.isUndefined(delay)? true : delay; 155 | this.children.forEach(function(c) { 156 | c.autoFlush(delay); 157 | }); 158 | delay !== false && this.flush(delay); 159 | return this; 160 | }, 161 | 162 | /** 163 | * Simulate a failure by specifying that the next invocation of methodName should 164 | * fail with the provided error. 165 | * 166 | * @param {String} methodName currently only supports `set` and `transaction` 167 | * @param {String|Error} error 168 | */ 169 | failNext: function(methodName, error) { 170 | this.errs[methodName] = error; 171 | }, 172 | 173 | /** 174 | * Returns a copy of the current data 175 | * @returns {*} 176 | */ 177 | getData: function() { 178 | return _.cloneDeep(this.data); 179 | }, 180 | 181 | /** 182 | * Returns the last automatically generated ID 183 | * @returns {string|string|*} 184 | */ 185 | getLastAutoId: function() { 186 | return 'mock'+PUSH_COUNTER; 187 | }, 188 | 189 | /***************************************************** 190 | * Firebase API methods 191 | *****************************************************/ 192 | 193 | toString: function() { 194 | return this.currentPath; 195 | }, 196 | 197 | child: function(childPath) { 198 | if( !childPath ) { throw new Error('bad child path '+this.toString()); } 199 | var parts = _.isArray(childPath)? childPath : childPath.split('/'); 200 | var childKey = parts.shift(); 201 | var child = _.find(this.children, function(c) { 202 | return c.name() === childKey; 203 | }); 204 | if( !child ) { 205 | child = new MockFirebase(mergePaths(this.currentPath, childKey), this._childData(childKey), this, childKey); 206 | child.flushDelay = this.flushDelay; 207 | } 208 | if( parts.length ) { 209 | child = child.child(parts); 210 | } 211 | return child; 212 | }, 213 | 214 | set: function(data, callback) { 215 | var self = this; 216 | var err = this._nextErr('set'); 217 | data = _.cloneDeep(data); 218 | DEBUG && console.log('set called',this.toString(), data); //debug 219 | this._defer(function() { 220 | DEBUG && console.log('set completed',this.toString(), data); //debug 221 | if( err === null ) { 222 | self._dataChanged(data); 223 | } 224 | callback && callback(err); 225 | }); 226 | return this; 227 | }, 228 | 229 | update: function(changes, callback) { 230 | if( !_.isObject(changes) ) { 231 | throw new Error('First argument must be an object when calling $update'); 232 | } 233 | var self = this; 234 | var err = this._nextErr('update'); 235 | var base = this.getData(); 236 | var data = _.assign(_.isObject(base)? base : {}, changes); 237 | DEBUG && console.log('update called', this.toString(), data); //debug 238 | this._defer(function() { 239 | DEBUG && console.log('update completed', this.toString(), data); //debug 240 | if( err === null ) { 241 | self._dataChanged(data); 242 | } 243 | callback && callback(err); 244 | }); 245 | }, 246 | 247 | setPriority: function(newPriority) { 248 | this._priChanged(newPriority); 249 | }, 250 | 251 | name: function() { 252 | return this.myName; 253 | }, 254 | 255 | ref: function() { 256 | return this; 257 | }, 258 | 259 | parent: function() { 260 | return this.parent? this.parent : this; 261 | }, 262 | 263 | root: function() { 264 | var next = this; 265 | while(next.parent()) { 266 | next = next.parent(); 267 | } 268 | return next; 269 | }, 270 | 271 | push: function(data) { 272 | var id = 'mock'+(++PUSH_COUNTER); 273 | var child = this.child(id); 274 | if( data ) { 275 | child.set(data); 276 | } 277 | return child; 278 | }, 279 | 280 | once: function(event, callback) { 281 | function fn(snap) { 282 | this.off(event, fn); 283 | callback(snap); 284 | } 285 | this.on(event, fn); 286 | }, 287 | 288 | remove: function() { 289 | this._dataChanged(null); 290 | }, 291 | 292 | on: function(event, callback) { //todo cancelCallback? 293 | this._events[event].push(callback); 294 | var data = this.getData(), self = this, pri = this.priority; 295 | if( event === 'value' ) { 296 | this._defer(function() { 297 | callback(makeSnap(self, data, pri)); 298 | }); 299 | } 300 | else if( event === 'child_added' ) { 301 | this._defer(function() { 302 | var prev = null; 303 | _.each(data, function(v, k) { 304 | callback(makeSnap(self.child(k), v, pri), prev); 305 | prev = k; 306 | }); 307 | }); 308 | } 309 | }, 310 | 311 | off: function(event, callback) { 312 | if( !event ) { 313 | for (var key in this._events) 314 | if( this._events.hasOwnProperty(key) ) 315 | this.off(key); 316 | } 317 | else if( callback ) { 318 | this._events[event] = _.without(this._events[event], callback); 319 | } 320 | else { 321 | this._events[event] = []; 322 | } 323 | }, 324 | 325 | transaction: function(valueFn, finishedFn, applyLocally) { 326 | var valueSpy = sinon.spy(valueFn); 327 | var finishedSpy = sinon.spy(finishedFn); 328 | this._defer(function() { 329 | var err = this._nextErr('transaction'); 330 | // unlike most defer methods, this will use the value as it exists at the time 331 | // the transaction is actually invoked, which is the eventual consistent value 332 | // it would have in reality 333 | var res = valueSpy(this.getData()); 334 | var newData = _.isUndefined(res) || err? this.getData() : res; 335 | finishedSpy(err, err === null && !_.isUndefined(res), makeSnap(this, newData, this.priority)); 336 | this._dataChanged(newData); 337 | }); 338 | return [valueSpy, finishedSpy, applyLocally]; 339 | }, 340 | 341 | /** 342 | * If token is valid and parses, returns the contents of token as exected. If not, the error is returned. 343 | * Does not change behavior in any way (since we don't really auth anywhere) 344 | * 345 | * @param {String} token 346 | * @param {Function} [callback] 347 | */ 348 | auth: function(token, callback) { 349 | //todo invoke callback with the parsed token contents 350 | callback && this._defer(callback); 351 | }, 352 | 353 | /** 354 | * Just a stub at this point. 355 | * @param {int} limit 356 | */ 357 | limit: function(limit) { 358 | this._queryProps.limit = limit; 359 | //todo 360 | }, 361 | 362 | startAt: function(priority, recordId) { 363 | this._queryProps.startAt = [priority, recordId]; 364 | //todo 365 | }, 366 | 367 | endAt: function(priority, recordId) { 368 | this._queryProps.endAt = [priority, recordId]; 369 | //todo 370 | }, 371 | 372 | /***************************************************** 373 | * Private/internal methods 374 | *****************************************************/ 375 | 376 | _childChanged: function(ref) { 377 | var data = ref.getData(), pri = ref.priority; 378 | if( !_.isObject(this.data) && data !== null ) { this.data = {}; } 379 | var exists = this.data.hasOwnProperty(ref.name()); 380 | DEBUG && console.log('_childChanged', this.toString() + ' -> ' + ref.name(), data); //debug 381 | if( data === null && exists ) { 382 | delete this.data[ref.name()]; 383 | this._trigger('child_removed', data, pri, ref.name()); 384 | this._trigger('value', this.data, this.priority); 385 | } 386 | else if( data !== null ) { 387 | this.data[ref.name()] = _.cloneDeep(data); 388 | this._trigger(exists? 'child_changed' : 'child_added', data, pri, ref.name()); 389 | this._trigger('value', this.data, this.priority); 390 | this.parent && this.parent._childChanged(this); 391 | } 392 | }, 393 | 394 | _dataChanged: function(data) { 395 | var self = this; 396 | if(_.isObject(data) && _.has(data, '.priority')) { 397 | this._priChanged(data['.priority']); 398 | delete data['.priority']; 399 | } 400 | if( !_.isEqual(data, this.data) ) { 401 | this.data = _.cloneDeep(data); 402 | DEBUG && console.log('_dataChanged', this.toString(), data); //debug 403 | this._trigger('value', this.data, this.priority); 404 | if(this.children.length) { 405 | this._resort(); 406 | _.each(this.children, function(child) { 407 | child._dataChanged(self._childData(child.name())); 408 | }); 409 | } 410 | if( this.parent && _.isObject(this.parent.data) ) { 411 | this.parent._childChanged(this); 412 | } 413 | } 414 | }, 415 | 416 | _priChanged: function(newPriority) { 417 | DEBUG && console.log('_priChanged', this.toString(), newPriority); //debug 418 | this.priority = newPriority; 419 | if( this.parent ) { 420 | this.parent._resort(this.name()); 421 | } 422 | }, 423 | 424 | _resort: function(childKeyMoved) { 425 | this.children.sort(childComparator); 426 | if( !_.isUndefined(childKeyMoved) ) { 427 | var child = this.child(childKeyMoved); 428 | this._trigger('child_moved', child.getData(), child.priority, childKeyMoved); 429 | } 430 | }, 431 | 432 | _defer: function(fn) { 433 | //todo should probably be taking some sort of snapshot of my data here and passing 434 | //todo that into `fn` for reference 435 | this.ops.push(Array.prototype.slice.call(arguments, 0)); 436 | if( this.flushDelay !== false ) { this.flush(this.flushDelay); } 437 | }, 438 | 439 | _trigger: function(event, data, pri, key) { 440 | var self = this, ref = event==='value'? self : self.child(key); 441 | var snap = makeSnap(ref, data, pri); 442 | _.each(self._events[event], function(fn) { 443 | if(_.contains(['child_added', 'child_moved'], event)) { 444 | fn(snap, self._getPrevChild(key, pri)); 445 | } 446 | else { 447 | //todo allow scope by changing fn to an array? for use with on() and once() which accept scope? 448 | fn(snap); 449 | } 450 | }); 451 | }, 452 | 453 | _nextErr: function(type) { 454 | var err = this.errs[type]; 455 | delete this.errs[type]; 456 | return err||null; 457 | }, 458 | 459 | _childData: function(key) { 460 | return _.isObject(this.data) && _.has(this.data, key)? this.data[key] : null; 461 | }, 462 | 463 | _getPrevChild: function(key, pri) { 464 | function keysMatch(c) { return c.name() === key } 465 | var recs = this.children; 466 | var i = _.findIndex(recs, keysMatch); 467 | if( i === -1 ) { 468 | recs = this.children.slice(); 469 | child = {name: function() { return key; }, priority: pri===undefined? null : pri }; 470 | recs.push(child); 471 | recs.sort(childComparator); 472 | i = _.findIndex(recs, keysMatch); 473 | } 474 | return i > 0? i : null; 475 | } 476 | }; 477 | 478 | 479 | /******************************************************************************* 480 | * SIMPLE LOGIN 481 | ******************************************************************************/ 482 | function MockFirebaseSimpleLogin(ref, callback, resultData) { 483 | // allows test units to monitor the callback function to make sure 484 | // it is invoked (even if one is not declared) 485 | this.callback = sinon.spy(callback||function() {}); 486 | this.attempts = []; 487 | this.failMethod = MockFirebaseSimpleLogin.DEFAULT_FAIL_WHEN; 488 | this.ref = ref; // we don't use ref for anything 489 | this.autoFlushTime = MockFirebaseSimpleLogin.DEFAULT_AUTO_FLUSH; 490 | this.resultData = _.cloneDeep(MockFirebaseSimpleLogin.DEFAULT_RESULT_DATA); 491 | resultData && _.assign(this.resultData, resultData); 492 | } 493 | 494 | MockFirebaseSimpleLogin.prototype = { 495 | 496 | /***************************************************** 497 | * Test Unit Methods 498 | *****************************************************/ 499 | 500 | /** 501 | * When this method is called, any outstanding login() 502 | * attempts will be immediately resolved. If this method 503 | * is called with an integer value, then the login attempt 504 | * will resolve asynchronously after that many milliseconds. 505 | * 506 | * @param {int|boolean} [milliseconds] 507 | * @returns {MockFirebaseSimpleLogin} 508 | */ 509 | flush: function(milliseconds) { 510 | var self = this; 511 | if(_.isNumber(milliseconds) ) { 512 | setTimeout(self.flush.bind(self), milliseconds); 513 | } 514 | else { 515 | var attempts = self.attempts; 516 | self.attempts = []; 517 | _.each(attempts, function(x) { 518 | x[0].apply(self, x.slice(1)); 519 | }); 520 | } 521 | return self; 522 | }, 523 | 524 | /** 525 | * Automatically queue the flush() event 526 | * each time login() is called. If this method 527 | * is called with `true`, then the callback 528 | * is invoked synchronously. 529 | * 530 | * If this method is called with an integer, 531 | * the callback is triggered asynchronously 532 | * after that many milliseconds. 533 | * 534 | * If this method is called with false, then 535 | * autoFlush() is disabled. 536 | * 537 | * @param {int|boolean} [milliseconds] 538 | * @returns {MockFirebaseSimpleLogin} 539 | */ 540 | autoFlush: function(milliseconds) { 541 | this.autoFlushTime = milliseconds; 542 | if( this.autoFlushTime !== false ) { 543 | this.flush(this.autoFlushTime); 544 | } 545 | return this; 546 | }, 547 | 548 | /** 549 | * `testMethod` is passed the {string}provider, {object}options, {object}user 550 | * for each call to login(). If it returns anything other than 551 | * null, then that is passed as the error message to the 552 | * callback and the login call fails. 553 | * 554 | * 555 | * // this is a simplified example of the default implementation (MockFirebaseSimpleLogin.DEFAULT_FAIL_WHEN) 556 | * auth.failWhen(function(provider, options, user) { 557 | * if( user.email !== options.email ) { 558 | * return MockFirebaseSimpleLogin.createError('INVALID_USER'); 559 | * } 560 | * else if( user.password !== options.password ) { 561 | * return MockFirebaseSimpleLogin.createError('INVALID_PASSWORD'); 562 | * } 563 | * else { 564 | * return null; 565 | * } 566 | * }); 567 | * 568 | * 569 | * Multiple calls to this method replace the old failWhen criteria. 570 | * 571 | * @param testMethod 572 | * @returns {MockFirebaseSimpleLogin} 573 | */ 574 | failWhen: function(testMethod) { 575 | this.failMethod = testMethod; 576 | return this; 577 | }, 578 | 579 | /** 580 | * Retrieves a user account from the mock user data on this object 581 | * 582 | * @param provider 583 | * @param options 584 | */ 585 | getUser: function(provider, options) { 586 | var data = this.resultData[provider]; 587 | if( provider === 'password' ) { 588 | data = (data||{})[options.email]; 589 | } 590 | return data||{}; 591 | }, 592 | 593 | /***************************************************** 594 | * Public API 595 | *****************************************************/ 596 | login: function(provider, options) { 597 | var err = this.failMethod(provider, options||{}, this.getUser(provider, options)); 598 | this._notify(err, err===null? this.resultData[provider]: null); 599 | }, 600 | 601 | logout: function() { 602 | this._notify(null, null); 603 | }, 604 | 605 | createUser: function(email, password, callback) { 606 | callback || (callback = _.noop); 607 | this._defer(function() { 608 | var user = this.resultData['password'][email] = createEmailUser(email, password); 609 | this.callback(null, user); 610 | }); 611 | }, 612 | 613 | changePassword: function(email, oldPassword, newPassword, callback) { 614 | callback || (callback = _.noop); 615 | this._defer(function() { 616 | var user = this.getUser('password', {email: email}); 617 | if( !user ) { 618 | callback(MockFirebaseSimpleLogin.createError('INVALID_USER'), false); 619 | } 620 | else if( oldPassword !== user.password ) { 621 | callback(MockFirebaseSimpleLogin.createError('INVALID_PASSWORD'), false); 622 | } 623 | else { 624 | user.password = newPassword; 625 | callback(null, true); 626 | } 627 | }); 628 | }, 629 | 630 | sendPasswordResetEmail: function(email, callback) { 631 | callback || (callback = _.noop); 632 | this._defer(function() { 633 | var user = this.getUser('password', {email: email}); 634 | if( user ) { 635 | callback(null, true); 636 | } 637 | else { 638 | callback(MockFirebaseSimpleLogin.createError('INVALID_USER'), false); 639 | } 640 | }); 641 | }, 642 | 643 | removeUser: function(email, password, callback) { 644 | callback || (callback = _.noop); 645 | this._defer(function() { 646 | var user = this.getUser('password', {email: email}); 647 | if( !user ) { 648 | callback(MockFirebaseSimpleLogin.createError('INVALID_USER'), false); 649 | } 650 | else if( user.password !== password ) { 651 | callback(MockFirebaseSimpleLogin.createError('INVALID_PASSWORD'), false); 652 | } 653 | else { 654 | delete this.resultData['password'][email]; 655 | callback(null, true); 656 | } 657 | }); 658 | }, 659 | 660 | /***************************************************** 661 | * Private/internal methods 662 | *****************************************************/ 663 | _notify: function(error, user) { 664 | this._defer(this.callback, error, user); 665 | }, 666 | 667 | _defer: function() { 668 | var args = _.toArray(arguments); 669 | this.attempts.push(args); 670 | if( this.autoFlushTime !== false ) { 671 | this.flush(this.autoFlushTime); 672 | } 673 | } 674 | }; 675 | 676 | /*** UTIL FUNCTIONS ***/ 677 | var USER_COUNT = 100; 678 | function createEmailUser(email, password) { 679 | var id = USER_COUNT++; 680 | return { 681 | uid: 'password:'+id, 682 | id: id, 683 | email: email, 684 | password: password, 685 | provider: 'password', 686 | md5_hash: MD5(email), 687 | firebaseAuthToken: 'FIREBASE_AUTH_TOKEN' //todo 688 | }; 689 | } 690 | 691 | function createDefaultUser(provider, i) { 692 | var id = USER_COUNT++; 693 | 694 | var out = { 695 | uid: provider+':'+id, 696 | id: id, 697 | password: id, 698 | provider: provider, 699 | firebaseAuthToken: 'FIREBASE_AUTH_TOKEN' //todo 700 | }; 701 | switch(provider) { 702 | case 'password': 703 | out.email = 'email@firebase.com'; 704 | out.md5_hash = MD5(out.email); 705 | break; 706 | case 'persona': 707 | out.email = 'email@firebase.com'; 708 | out.md5_hash = MD5(out.email); 709 | break; 710 | case 'twitter': 711 | out.accessToken = 'ACCESS_TOKEN'; //todo 712 | out.accessTokenSecret = 'ACCESS_TOKEN_SECRET'; //todo 713 | out.displayName = 'DISPLAY_NAME'; 714 | out.thirdPartyUserData = {}; //todo 715 | out.username = 'USERNAME'; 716 | break; 717 | case 'google': 718 | out.accessToken = 'ACCESS_TOKEN'; //todo 719 | out.displayName = 'DISPLAY_NAME'; 720 | out.email = 'email@firebase.com'; 721 | out.thirdPartyUserData = {}; //todo 722 | break; 723 | case 'github': 724 | out.accessToken = 'ACCESS_TOKEN'; //todo 725 | out.displayName = 'DISPLAY_NAME'; 726 | out.thirdPartyUserData = {}; //todo 727 | out.username = 'USERNAME'; 728 | break; 729 | case 'facebook': 730 | out.accessToken = 'ACCESS_TOKEN'; //todo 731 | out.displayName = 'DISPLAY_NAME'; 732 | out.thirdPartyUserData = {}; //todo 733 | break; 734 | case 'anonymous': 735 | break; 736 | default: 737 | throw new Error('Invalid auth provider', provider); 738 | } 739 | 740 | return out; 741 | } 742 | 743 | function ref(path, autoSyncDelay) { 744 | var ref = new MockFirebase(); 745 | ref.flushDelay = _.isUndefined(autoSyncDelay)? true : autoSyncDelay; 746 | if( path ) { ref = ref.child(path); } 747 | return ref; 748 | } 749 | 750 | function mergePaths(base, add) { 751 | return base.replace(/\/$/, '')+'/'+add.replace(/^\//, ''); 752 | } 753 | 754 | function makeSnap(ref, data, pri) { 755 | data = _.cloneDeep(data); 756 | return { 757 | val: function() { return data; }, 758 | ref: function() { return ref; }, 759 | name: function() { return ref.name() }, 760 | getPriority: function() { return pri; }, //todo 761 | forEach: function(cb, scope) { 762 | _.each(data, function(v, k, list) { 763 | var child = ref.child(k); 764 | //todo the priority here is inaccurate if child pri modified 765 | //todo between calling makeSnap and forEach() on that snap 766 | var res = cb.call(scope, makeSnap(child, v, child.priority)); 767 | return !(res === true); 768 | }); 769 | } 770 | } 771 | } 772 | 773 | function extractName(path) { 774 | return ((path || '').match(/\/([^.$\[\]#\/]+)$/)||[null, null])[1]; 775 | } 776 | 777 | function childComparator(a, b) { 778 | if(a.priority === b.priority) { 779 | var key1 = a.name(), key2 = b.name(); 780 | return ( ( key1 == key2 ) ? 0 : ( ( key1 > key2 ) ? 1 : -1 ) ); 781 | } 782 | else { 783 | return a.priority < b.priority? -1 : 1; 784 | } 785 | } 786 | 787 | // a polyfill for window.atob to allow JWT token parsing 788 | // credits: https://github.com/davidchambers/Base64.js 789 | ;(function (object) { 790 | var chars = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/='; 791 | 792 | function InvalidCharacterError(message) { 793 | this.message = message; 794 | } 795 | InvalidCharacterError.prototype = new Error; 796 | InvalidCharacterError.prototype.name = 'InvalidCharacterError'; 797 | 798 | // encoder 799 | // [https://gist.github.com/999166] by [https://github.com/nignag] 800 | object.btoa || ( 801 | object.btoa = function (input) { 802 | for ( 803 | // initialize result and counter 804 | var block, charCode, idx = 0, map = chars, output = ''; 805 | // if the next input index does not exist: 806 | // change the mapping table to "=" 807 | // check if d has no fractional digits 808 | input.charAt(idx | 0) || (map = '=', idx % 1); 809 | // "8 - idx % 1 * 8" generates the sequence 2, 4, 6, 8 810 | output += map.charAt(63 & block >> 8 - idx % 1 * 8) 811 | ) { 812 | charCode = input.charCodeAt(idx += 3/4); 813 | if (charCode > 0xFF) { 814 | throw new InvalidCharacterError("'btoa' failed: The string to be encoded contains characters outside of the Latin1 range."); 815 | } 816 | block = block << 8 | charCode; 817 | } 818 | return output; 819 | }); 820 | 821 | // decoder 822 | // [https://gist.github.com/1020396] by [https://github.com/atk] 823 | object.atob || ( 824 | object.atob = function (input) { 825 | input = input.replace(/=+$/, '') 826 | if (input.length % 4 == 1) { 827 | throw new InvalidCharacterError("'atob' failed: The string to be decoded is not correctly encoded."); 828 | } 829 | for ( 830 | // initialize result and counters 831 | var bc = 0, bs, buffer, idx = 0, output = ''; 832 | // get next character 833 | buffer = input.charAt(idx++); 834 | // character found in table? initialize bit storage and add its ascii value; 835 | ~buffer && (bs = bc % 4 ? bs * 64 + buffer : buffer, 836 | // and if not first of each 4 characters, 837 | // convert the first 8 bits to one ascii character 838 | bc++ % 4) ? output += String.fromCharCode(255 & bs >> (-2 * bc & 6)) : 0 839 | ) { 840 | // try to find character in table (0-63, not found => -1) 841 | buffer = chars.indexOf(buffer); 842 | } 843 | return output; 844 | }); 845 | 846 | }(exports)); 847 | 848 | // MD5 (Message-Digest Algorithm) by WebToolkit 849 | // 850 | 851 | var MD5=function(s){function L(k,d){return(k<>>(32-d))}function K(G,k){var I,d,F,H,x;F=(G&2147483648);H=(k&2147483648);I=(G&1073741824);d=(k&1073741824);x=(G&1073741823)+(k&1073741823);if(I&d){return(x^2147483648^F^H)}if(I|d){if(x&1073741824){return(x^3221225472^F^H)}else{return(x^1073741824^F^H)}}else{return(x^F^H)}}function r(d,F,k){return(d&F)|((~d)&k)}function q(d,F,k){return(d&k)|(F&(~k))}function p(d,F,k){return(d^F^k)}function n(d,F,k){return(F^(d|(~k)))}function u(G,F,aa,Z,k,H,I){G=K(G,K(K(r(F,aa,Z),k),I));return K(L(G,H),F)}function f(G,F,aa,Z,k,H,I){G=K(G,K(K(q(F,aa,Z),k),I));return K(L(G,H),F)}function D(G,F,aa,Z,k,H,I){G=K(G,K(K(p(F,aa,Z),k),I));return K(L(G,H),F)}function t(G,F,aa,Z,k,H,I){G=K(G,K(K(n(F,aa,Z),k),I));return K(L(G,H),F)}function e(G){var Z;var F=G.length;var x=F+8;var k=(x-(x%64))/64;var I=(k+1)*16;var aa=Array(I-1);var d=0;var H=0;while(H>>29;return aa}function B(x){var k="",F="",G,d;for(d=0;d<=3;d++){G=(x>>>(d*8))&255;F="0"+G.toString(16);k=k+F.substr(F.length-2,2)}return k}function J(k){k=k.replace(/rn/g,"n");var d="";for(var F=0;F127)&&(x<2048)){d+=String.fromCharCode((x>>6)|192);d+=String.fromCharCode((x&63)|128)}else{d+=String.fromCharCode((x>>12)|224);d+=String.fromCharCode(((x>>6)&63)|128);d+=String.fromCharCode((x&63)|128)}}}return d}var C=Array();var P,h,E,v,g,Y,X,W,V;var S=7,Q=12,N=17,M=22;var A=5,z=9,y=14,w=20;var o=4,m=11,l=16,j=23;var U=6,T=10,R=15,O=21;s=J(s);C=e(s);Y=1732584193;X=4023233417;W=2562383102;V=271733878;for(P=0;P