├── .gitignore ├── .npmignore ├── .travis.yml ├── src ├── defaults.js ├── errors.js ├── lib │ ├── google-geocoder.js │ └── parallel-transform.js ├── cache.js ├── geocode-stream.js ├── index.js └── geocoder.js ├── .editorconfig ├── example └── example.js ├── .release.json ├── test ├── cache-test.js ├── parallel-transform.js ├── helpers-test.js ├── lib │ └── helpers.js ├── index-test.js ├── geocoder-test.js └── geocode-stream-test.js ├── package.json ├── CONVENTIONS.md ├── .eslintrc ├── README.md └── CHANGELOG.md /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules/ 2 | geocache.db 3 | dist 4 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | src 2 | test 3 | .travis.yml 4 | .release.json 5 | CONVENTIONS.md 6 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | language: node_js 2 | node_js: 3 | - "0.12" 4 | - "4" 5 | 6 | cache: 7 | directories: 8 | - node_modules 9 | 10 | sudo: false 11 | -------------------------------------------------------------------------------- /src/defaults.js: -------------------------------------------------------------------------------- 1 | export default { 2 | maxQueriesPerSecond: 50, 3 | minQueriesPerSecond: 1, 4 | defaultQueriesPerSecond: 35, 5 | maxRetries: 0, 6 | bucketDuration: 1000 // ms 7 | }; 8 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | # http://editorconfig.org 2 | root = true 3 | 4 | [*] 5 | indent_style = space 6 | indent_size = 2 7 | end_of_line = lf 8 | charset = utf-8 9 | trim_trailing_whitespace = true 10 | insert_final_newline = true 11 | -------------------------------------------------------------------------------- /src/errors.js: -------------------------------------------------------------------------------- 1 | /** 2 | * Mapping from error codes to error messages 3 | */ 4 | export default { 5 | 'ENOTFOUND': 'Could not connect to the Google Maps API (DNS)', 6 | 'ECONNREFUSED': 'Could not connect to the Google Maps API', 7 | '403': 'Authentication error' 8 | }; 9 | -------------------------------------------------------------------------------- /example/example.js: -------------------------------------------------------------------------------- 1 | import GeoBatch from '../src/index'; 2 | 3 | const accessor = input => input.address, 4 | input = [ 5 | { 6 | address: 'Hamburg' 7 | }, 8 | { 9 | address: 'Berlin' 10 | } 11 | ], 12 | geoBatch = new GeoBatch({accessor}); 13 | 14 | geoBatch.geocode(input) 15 | .on('data', result => { 16 | console.log(result); // eslint-disable-line 17 | }); 18 | -------------------------------------------------------------------------------- /.release.json: -------------------------------------------------------------------------------- 1 | { 2 | "non-interactive": true, 3 | "dry-run": false, 4 | "verbose": true, 5 | "force": false, 6 | "pkgFiles": ["package.json"], 7 | "increment": "patch", 8 | "commitMessage": "chore(release): %s", 9 | "tagName": "%s", 10 | "tagAnnotation": "Release %s", 11 | "buildCommand": "npm run changelog", 12 | "npm": { 13 | "private": false, 14 | "publish": true, 15 | "publishPath": "." 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/lib/google-geocoder.js: -------------------------------------------------------------------------------- 1 | import GoogleMapsAPI from 'googlemaps'; 2 | 3 | const defaults = { 4 | google_client_id: null, // eslint-disable-line camelcase 5 | google_private_key: null, // eslint-disable-line camelcase 6 | key: null 7 | }; 8 | 9 | export default class GoogleGeocoder { 10 | 11 | /** 12 | * Inits a google maps API instance with give clientID and privateKey. 13 | * @param {Object} options Config Object 14 | * @return {Object} Instance of google maps API 15 | */ 16 | static init(options = {}) { 17 | options = Object.assign({}, defaults, options); 18 | return new GoogleMapsAPI(options); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /src/cache.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable one-var */ 2 | import flatfile from 'flat-file-db'; 3 | 4 | /** 5 | * Cache instance to store key value pairs. 6 | * @type {Class} 7 | * @param {String} cacheFile The name of the file to cache 8 | */ 9 | export default class Cache { 10 | /** 11 | * Constructs the Cache. 12 | * @param {String} cacheFile The filename for the cache. 13 | */ 14 | constructor(cacheFile = 'geocache.db') { 15 | this.db = flatfile.sync(cacheFile); 16 | } 17 | 18 | /** 19 | * Add new entries to the Cache 20 | * @param {String} key The key that shall be cached 21 | * @param {Object} value The value that should be stored in the cache 22 | * @param {Function} callback The callback 23 | */ 24 | add(key, value, callback = () => {}) { 25 | this.db.put(key, value, error => { 26 | if (error) { 27 | throw error; 28 | } 29 | 30 | callback(); 31 | }); 32 | } 33 | 34 | /** 35 | * Add new entries to the Cache 36 | * @param {String} key The key that should be retrieved 37 | * @return {Object} The value 38 | */ 39 | get(key) { 40 | return this.db.get(key); 41 | } 42 | } 43 | -------------------------------------------------------------------------------- /test/cache-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import should from 'should'; 3 | import fs from 'fs'; 4 | import Cache from '../src/cache.js'; 5 | 6 | describe('Testing cache', () => { 7 | afterEach(function(done) { 8 | fs.exists('geocache.db', exists => { 9 | if (exists) { 10 | fs.unlinkSync('geocache.db'); 11 | } 12 | 13 | done(); 14 | }); 15 | }); 16 | 17 | it('should create a new instance when called without params', () => { 18 | const cache = new Cache(); 19 | 20 | should.exist(cache); 21 | }); 22 | 23 | it('should create a new cache file when not existing', done => { 24 | const cache = new Cache(); // eslint-disable-line 25 | 26 | fs.exists('geocache.db', function(exists) { 27 | should(exists).be.true; 28 | done(); 29 | }); 30 | }); 31 | 32 | it('should create a new cache file depending on the name', done => { 33 | const cache = new Cache('myPersonalGeocache.db'); // eslint-disable-line 34 | 35 | fs.exists('myPersonalGeocache.db', function(exists) { 36 | should(exists).be.true; 37 | fs.unlinkSync('myPersonalGeocache.db'); 38 | done(); 39 | }); 40 | }); 41 | 42 | it('should be possible to add and retrieve new entries', done => { 43 | const cache = new Cache(); 44 | 45 | cache.add('MyLocation', {lat: 50, lng: 10}, function() { 46 | should.exist(cache.get('MyLocation')); 47 | should.deepEqual(cache.get('MyLocation'), {lat: 50, lng: 10}); 48 | done(); 49 | }); 50 | }); 51 | 52 | it('should return nothing when entry not exists', () => { 53 | const cache = new Cache(); 54 | 55 | should.not.exist(cache.get('MyLocation')); 56 | }); 57 | }); 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "geobatch", 3 | "description": "Batch geocode addresses from multiple sources.", 4 | "version": "1.4.3", 5 | "license": "MIT", 6 | "author": { 7 | "name": "Robert Katzki", 8 | "email": "katzki@ubilabs.net", 9 | "url": "http://ubilabs.net/" 10 | }, 11 | "contributors": [ 12 | { 13 | "name": "Patrick Mast", 14 | "email": "mast@ubilabs.net", 15 | "url": "http://ubilabs.net/" 16 | }, 17 | { 18 | "name": "Keno Schwalb", 19 | "email": "schwalb@ubilabs.net", 20 | "url": "https://www.ubilabs.net/" 21 | } 22 | ], 23 | "repository": { 24 | "type": "git", 25 | "url": "https://github.com/ubilabs/node-geobatch" 26 | }, 27 | "main": "dist", 28 | "scripts": { 29 | "build": "babel src --out-dir dist", 30 | "prepublish": "npm run lint && npm run build", 31 | "pretest": "npm run lint", 32 | "test": "mocha --compilers js:babel-core/register", 33 | "generatechangelog": "conventional-changelog -i CHANGELOG.md -s -p angular", 34 | "commitchangelog": "git add CHANGELOG.md && git commit -m \"chore(changelog): new version\" && git push origin master", 35 | "changelog": "npm run generatechangelog && git reset package.json && npm run commitchangelog && git add package.json", 36 | "release": "git reset && release-it", 37 | "lint": "eslint ./src ./test" 38 | }, 39 | "dependencies": { 40 | "amp-is-empty": "^1.0.2", 41 | "cyclist": "^1.0.1", 42 | "flat-file-db": "^1.0.0", 43 | "googlemaps": "^1.6.0", 44 | "into-stream": "^2.0.0" 45 | }, 46 | "devDependencies": { 47 | "babel": "^5.8.23", 48 | "babel-core": "^5.8.25", 49 | "conventional-changelog-cli": "^1.2.0", 50 | "eslint": "^2.13.1", 51 | "lie": "^3.0.1", 52 | "mocha": "^2.5.0", 53 | "release-it": "^2.4.0", 54 | "should": "^9.0.0", 55 | "sinon": "^1.17.1", 56 | "stream-assert": "^2.0.3" 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/geocode-stream.js: -------------------------------------------------------------------------------- 1 | import ParallelTransform from './lib/parallel-transform'; 2 | 3 | /** 4 | * A streaming object for the geocode 5 | * @param {Object} geocoder The geocoder 6 | */ 7 | export default class GeocodeStream extends ParallelTransform { 8 | /** 9 | * Constructs a geocodeStream. 10 | * @param {Object} geocoder A geocoder. 11 | * @param {Number} queriesPerSecond The number of queries per second 12 | * @param {Object} stats A statistics object. 13 | * @param {Function} accessor An accessor function that returns the address 14 | * from the data item. The default returns the 15 | * data item directly. 16 | */ 17 | constructor(geocoder, 18 | queriesPerSecond, 19 | stats = {current: 0}, 20 | accessor = address => address 21 | ) { 22 | super(queriesPerSecond, {objectMode: true}); 23 | 24 | this.geocoder = geocoder; 25 | this.stats = stats; 26 | this.accessor = accessor; 27 | } 28 | 29 | /** 30 | * The _parallelTransform function for the stream. 31 | * @param {String} input The address to geocode 32 | * @param {Function} done The done callback function 33 | */ 34 | _parallelTransform(input, done) { // eslint-disable-line 35 | const data = this.getMetaInfo(input); 36 | 37 | this.geocoder.geocodeAddress(this.accessor(input)) 38 | .then(results => { 39 | data.result = results[0]; 40 | data.results = results; 41 | data.location = results[0].geometry.location; 42 | done(null, data); 43 | }) 44 | .catch(error => { 45 | data.error = error.message; 46 | done(null, data); 47 | }); 48 | } 49 | 50 | /** 51 | * Get the result meta information 52 | * @param {String} input The input 53 | * @return {Object} The meta information 54 | */ 55 | getMetaInfo(input) { 56 | this.stats.current++; 57 | 58 | let metaInfo = { 59 | error: null, 60 | address: this.accessor(input), 61 | input: input, 62 | location: {}, 63 | result: {}, 64 | current: this.stats.current 65 | }; 66 | 67 | if (this.stats.hasOwnProperty('total')) { 68 | const now = new Date(), 69 | ratio = this.stats.current / this.stats.total; 70 | 71 | Object.assign(metaInfo, { 72 | total: this.stats.total, 73 | pending: this.stats.total - this.stats.current, 74 | percent: ratio * 100, 75 | estimatedDuration: Math.round((now - this.stats.startTime) / ratio) 76 | }); 77 | } 78 | return metaInfo; 79 | } 80 | } 81 | -------------------------------------------------------------------------------- /test/parallel-transform.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import should from 'should'; 3 | import sinon from 'sinon'; 4 | import ParallelTransform from '../src/lib/parallel-transform'; 5 | import {getParallelTransformStream} from './lib/helpers'; 6 | 7 | describe('Testing ParallelTransform', () => { 8 | it('should call a child\'s _parallelTransform function on write', done => { 9 | const transformStub = sinon.stub().callsArgWith(1), 10 | ParallelTransformStub = getParallelTransformStream(transformStub), 11 | transformInstance = new ParallelTransformStub(), 12 | data = 'some data'; 13 | 14 | transformInstance.on('data', () => {}); 15 | transformInstance.on('end', () => { 16 | should(transformStub.calledOnce).equal(true); 17 | should(transformStub.args[0][0].toString()).equal(data); 18 | done(); 19 | }); 20 | 21 | transformInstance.write(data); 22 | transformInstance.end(); 23 | }); 24 | 25 | it('should emit the _parallelTransform function\'s data', done => { 26 | const result = 'some result', 27 | ParallelTransformStub = getParallelTransformStream( 28 | sinon.stub().callsArgWith(1, null, result) 29 | ), 30 | transformInstance = new ParallelTransformStub(); 31 | 32 | transformInstance.on('data', data => should(data.toString()).equal(result)); 33 | transformInstance.on('end', done); 34 | 35 | transformInstance.write('some data'); 36 | transformInstance.end(); 37 | }); 38 | 39 | it('should throw an error when not implementing _parallelTransform', () => { 40 | class BrokenParallelTransform extends ParallelTransform {} 41 | const transformInstance = new BrokenParallelTransform(); 42 | 43 | should(() => { 44 | transformInstance.write('some data'); 45 | }).throw('Not implemented'); 46 | }); 47 | 48 | it('should pass options on to the stream.Transform constructor', done => { 49 | const transformStub = sinon.stub().callsArg(1), 50 | data = {someKey: 'someValue'}, 51 | maxParallel = 1, 52 | options = {objectMode: true}, 53 | ParallelTransformStub = getParallelTransformStream( 54 | transformStub, 55 | maxParallel, 56 | options 57 | ), 58 | transformInstance = new ParallelTransformStub(); 59 | 60 | transformInstance.on('data', result => { 61 | should(result).deepEqual(data); 62 | }); 63 | 64 | transformInstance.on('end', () => { 65 | should(transformStub.calledOnce).equal(true); 66 | done(); 67 | }); 68 | 69 | transformInstance.write(data); 70 | transformInstance.end(); 71 | }); 72 | }); 73 | -------------------------------------------------------------------------------- /CONVENTIONS.md: -------------------------------------------------------------------------------- 1 | ## Git Commit Guidelines 2 | 3 | These rules are adopted from [the AngularJS commit conventions](https://docs.google.com/document/d/1QrDFcIiPjSLDn3EL15IJygNPiHORgU1_OOAqWjiDU5Y/). 4 | 5 | ### Commit Message Format 6 | 7 | Each commit message starts with a **type**, a **scope**, and a **subject**. 8 | 9 | Below that, the commit message has a **body**. 10 | 11 | - **type**: what type of change this commit contains. 12 | - **scope**: what item of code this commit is changing. 13 | - **subject**: a short description of the changes. 14 | - **body** (optional): a more in-depth description of the changes 15 | 16 | ``` 17 | (): 18 | 19 | 20 | ``` 21 | 22 | Examples: 23 | ``` 24 | feat(ruler): add inches as well as centimeters 25 | ``` 26 | ``` 27 | fix(protractor): fix 90 degrees counting as 91 degrees 28 | ``` 29 | ``` 30 | refactor(pencil): use graphite instead of lead 31 | 32 | Closes #640. 33 | 34 | Graphite is a much more available resource than lead, so we use it to lower the price. 35 | ``` 36 | ``` 37 | fix(pen): use blue ink instead of red ink 38 | 39 | BREAKING CHANGE: Pen now uses blue ink instead of red. 40 | 41 | To migrate, change your code from the following: 42 | 43 | `pen.draw('blue')` 44 | 45 | To: 46 | 47 | `pen.draw('red')` 48 | ``` 49 | 50 | Any line of the commit message should not be longer 100 characters. This allows the message to be easier to read on github as well as in various git tools. 51 | 52 | ### Type 53 | Is recommended to be one of the below items. Only **feat** and **fix** show up in the changelog, in addition to breaking changes (see breaking changes section at bottom). 54 | 55 | * **feat**: A new feature 56 | * **fix**: A bug fix 57 | * **docs**: Documentation only changes 58 | * **style**: Changes that do not affect the meaning of the code (white-space, formatting, missing semi-colons, etc) 59 | * **refactor**: A code change that neither fixes a bug or adds a feature 60 | * **test**: Adding missing tests 61 | * **chore**: Changes to the build process or auxiliary tools and libraries such as documentation generation 62 | 63 | ### Scope 64 | The scope could be anything specifying place of the commit change. For example `$location`, 65 | `$browser`, `$compile`, `$rootScope`, `ngHref`, `ngClick`, `ngView`, etc... 66 | 67 | ### Subject 68 | The subject contains succinct description of the change: 69 | 70 | * use the imperative, present tense: "change" not "changed" nor "changes" 71 | * don't capitalize first letter 72 | * no dot (.) at the end 73 | 74 | ### Breaking Changes 75 | Put any breaking changes with migration instructions in the commit body. 76 | 77 | If there is a breaking change, put **BREAKING CHANGE:** in your commit body, and it will show up in the changelog. 78 | -------------------------------------------------------------------------------- /src/index.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable one-var */ 2 | 3 | import intoStream from 'into-stream'; 4 | import StandardGeocoder from './geocoder'; 5 | import StandardGeocodeStream from './geocode-stream'; 6 | import stream from 'stream'; 7 | import defaults from './defaults'; 8 | 9 | /** 10 | * GeoBatch instance 11 | * @type {Function} 12 | * @param {Object} options The options for the GeoBatch, default is 13 | * {cacheFile: 'geocache.db', 14 | * clientId: null, 15 | * privateKey: null} 16 | * @param {Object} Geocoder A geocoder. 17 | * @param {Object} GeocodeStream A GeocodeStream. 18 | */ 19 | export default class GeoBatch { 20 | constructor( 21 | { 22 | cacheFile = 'geocache.db', 23 | clientId = null, 24 | privateKey = null, 25 | apiKey = null, 26 | maxRetries = defaults.maxRetries, 27 | queriesPerSecond = defaults.defaultQueriesPerSecond, 28 | accessor = address => address 29 | } = { 30 | cacheFile: 'geocache.db', 31 | clientId: null, 32 | privateKey: null, 33 | apiKey: null, 34 | maxRetries: defaults.maxRetries, 35 | queriesPerSecond: defaults.defaultQueriesPerSecond, 36 | accessor: address => address 37 | }, 38 | Geocoder = StandardGeocoder, 39 | GeocodeStream = StandardGeocodeStream 40 | ) { 41 | this.geocoder = new Geocoder({ 42 | cacheFile, 43 | clientId, 44 | privateKey, 45 | apiKey, 46 | maxRetries, 47 | queriesPerSecond 48 | }); 49 | this.GeocodeStream = GeocodeStream; 50 | this.accessor = accessor; 51 | this.queriesPerSecond = queriesPerSecond; 52 | } 53 | 54 | /** 55 | * Geocode the passed in addresses 56 | * @param {Array/Stream} addresses The addresses to geocode 57 | * @return {Function} The stream 58 | */ 59 | geocode(addresses) { 60 | // If input is already stream, pass through directly. 61 | if (addresses instanceof stream) { 62 | return this.geocodeStream(addresses); 63 | } 64 | 65 | const arrayStream = intoStream.obj(addresses), 66 | stats = { 67 | total: addresses.length, 68 | current: 0, 69 | startTime: new Date() 70 | }; 71 | 72 | return this.geocodeStream(arrayStream, stats); 73 | } 74 | 75 | /** 76 | * Geocode the elements of a passed in stream. 77 | * @param {Stream} inputStream An input stream 78 | * @param {Object} stats An object with the stream stats, defaults to {}. 79 | * @return {Stream} A transformable stream. 80 | */ 81 | geocodeStream(inputStream, stats = {current: 0}) { 82 | const geocodeStream = new this.GeocodeStream( 83 | this.geocoder, 84 | this.queriesPerSecond, 85 | stats, 86 | this.accessor 87 | ); 88 | inputStream.pipe(geocodeStream); 89 | 90 | return geocodeStream; 91 | } 92 | } 93 | -------------------------------------------------------------------------------- /test/helpers-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions */ 2 | import stream from 'stream'; 3 | import Promise from 'lie'; 4 | 5 | import should from 'should'; 6 | import sinon from 'sinon'; 7 | 8 | import { 9 | getGeocodeFunction, 10 | getGeocoderInterface, 11 | getGeocodeStream 12 | } from './lib/helpers'; 13 | 14 | describe('Helper functions ', () => { 15 | describe('getGeocoderInterface', () => { 16 | it('should return a geocoder interface', () => { 17 | const geocoderInterface = getGeocoderInterface(); 18 | should(geocoderInterface.init).be.Function(); 19 | }); 20 | 21 | it(`should return a geocoder interface which returna geocoder with 22 | a given geocode function`, () => { 23 | const mockGeocodeFunction = () => {}, 24 | geocoderInterface = getGeocoderInterface(mockGeocodeFunction), 25 | geocoder = geocoderInterface.init(), 26 | geocodeFunction = geocoder.geocode; 27 | should(geocodeFunction).deepEqual(mockGeocodeFunction); 28 | }); 29 | 30 | it(`should return a geocoder interface which return a geocoder with 31 | a given geocodeAddress function`, () => { 32 | const mockGeocodeAddressFunction = () => {}, 33 | geocoderInterface = getGeocoderInterface( 34 | null, 35 | mockGeocodeAddressFunction 36 | ), 37 | geocoder = geocoderInterface.init(), 38 | geocodeAddressFunction = geocoder.geocodeAddress; 39 | should(geocodeAddressFunction).deepEqual(mockGeocodeAddressFunction); 40 | }); 41 | }); 42 | 43 | describe('getGeocodeFunction', () => { 44 | it('should return a default geocode function', () => { 45 | const mockGeocodeFunction = getGeocodeFunction(); 46 | 47 | should(mockGeocodeFunction).be.a.Function; 48 | }); 49 | 50 | it('should call callback', () => { 51 | const mockGeocodeFunction = getGeocodeFunction(), 52 | callBack = sinon.stub(); 53 | 54 | mockGeocodeFunction(null, callBack); 55 | sinon.assert.called(callBack); 56 | }); 57 | 58 | it('should take an error value', () => { 59 | const errorMessage = 'an Error', 60 | mockGeocodeFunction = getGeocodeFunction({error: errorMessage}), 61 | callBack = sinon.stub(); 62 | 63 | mockGeocodeFunction(null, callBack); 64 | sinon.assert.calledWith(callBack, errorMessage); 65 | }); 66 | 67 | it('should take a results object', () => { 68 | const mockResults = 'some results', 69 | mockGeocodeFunction = getGeocodeFunction({results: mockResults}), 70 | callBack = sinon.stub(), 71 | expectedArgument = {status: '', results: mockResults}; 72 | 73 | mockGeocodeFunction(null, callBack); 74 | sinon.assert.calledWith(callBack, '', expectedArgument); 75 | }); 76 | 77 | it('should take a status object', () => { 78 | const mockStatus = 'some status', 79 | mockGeocodeFunction = getGeocodeFunction({status: mockStatus}), 80 | callBack = sinon.stub(), 81 | expectedArgument = {results: '', status: mockStatus}; 82 | 83 | mockGeocodeFunction(null, callBack); 84 | sinon.assert.calledWith(callBack, '', expectedArgument); 85 | }); 86 | }); 87 | 88 | describe('getGeocodeStream', () => { 89 | it('should return a stream', () => { 90 | const mockPromise = Promise.resolve(); 91 | const test = getGeocodeStream(mockPromise); 92 | should(test).be.instanceof(stream); 93 | }); 94 | }); 95 | }); 96 | /* eslint-enable */ 97 | -------------------------------------------------------------------------------- /test/lib/helpers.js: -------------------------------------------------------------------------------- 1 | import sinon from 'sinon'; 2 | import GeocodeStream from '../../src/geocode-stream'; 3 | import ParallelTransform from '../../src/lib/parallel-transform'; 4 | import defaults from '../../src/defaults'; 5 | 6 | const helpers = { 7 | /** 8 | * Returns a geocode function to be used in the geocoder. 9 | * @param {Object} status The status of the geocode repsonse 10 | * @param {Object} results The results of the geocode response 11 | * @param {Object} error The error message of the geocode response 12 | * @return {Function} A stubbed function that calls the second argument 13 | * as a callback. 14 | */ 15 | getGeocodeFunction: ({status = '', results = '', error = ''} 16 | = {status: '', results: '', error: ''}) => { 17 | const geoCoderReponseObject = { 18 | status, 19 | results 20 | }; 21 | return sinon.stub() 22 | .callsArgWith(1, error, geoCoderReponseObject); 23 | }, 24 | 25 | /** 26 | * Returns a geocoder interface. The init function returns a geocoder that 27 | * exposes a geocode function. 28 | * @param {Function} geocodeFunction The geocode function to be exposed. 29 | * @param {Function} geocodeAddressFunction The geocodeAddress function 30 | to be exposed 31 | * @return {Object} A geocoder interface object. 32 | */ 33 | getGeocoderInterface: ( 34 | geocodeFunction = () => {}, 35 | geocodeAddressFunction = () => {} 36 | ) => { 37 | let geoCoderInterface = {init: () => { 38 | return { 39 | geocode: geocodeFunction, 40 | geocodeAddress: geocodeAddressFunction 41 | }; 42 | }}; 43 | return geoCoderInterface; 44 | }, 45 | 46 | /** 47 | * Returns an options object for the Geocoder constructor with 48 | * required default options added 49 | * @param {Object} opts desired extra options 50 | * @return {Object} input options, with added default options 51 | */ 52 | getGeocoderOptions: opts => { 53 | const defaultOptions = { 54 | cacheFile: 'geocache.db', 55 | clientId: null, 56 | privateKey: null, 57 | apiKey: 'dummy', 58 | maxRetries: 0, 59 | queriesPerSecond: defaults.defaultQueriesPerSecond 60 | }; 61 | 62 | return Object.assign({}, defaultOptions, opts); 63 | }, 64 | 65 | /** 66 | * Returns a mock geocoderStream. 67 | * @param {Promise} geocoderPromise The geocode promise returned by the 68 | * geocoder. 69 | * @param {Number} queriesPerSecond The queries per second 70 | * @return {Stream} A mock geocoder stream.- 71 | */ 72 | getGeocodeStream: ( 73 | geocoderPromise, 74 | queriesPerSecond = defaults.defaultQueriesPerSecond 75 | ) => { 76 | const mockGeocodeAddressFunction = () => geocoderPromise, 77 | GeoCoderInterface = helpers.getGeocoderInterface( 78 | null, 79 | mockGeocodeAddressFunction 80 | ), 81 | mockGeocoder = GeoCoderInterface.init(), 82 | mockStats = {}; 83 | return new GeocodeStream(mockGeocoder, queriesPerSecond, mockStats); 84 | }, 85 | 86 | /** 87 | * Returns a mock ParallelTransform stream 88 | * @param {Function} parallelTransform The transformation function 89 | * @param {Number} maxParallel The maximum number of 90 | parallel transformations 91 | * @param {Object} options ParallelTransform options 92 | * @return {Stream} A ParallelTransform stream 93 | **/ 94 | getParallelTransformStream: ( 95 | parallelTransform = (data, done) => { 96 | done(null, data); 97 | }, 98 | maxParallel = 1, 99 | options = {} 100 | ) => { 101 | class TransformTestClass extends ParallelTransform { 102 | constructor() { 103 | super(maxParallel, options); 104 | } 105 | } 106 | 107 | TransformTestClass.prototype // eslint-disable-line no-underscore-dangle 108 | ._parallelTransform = parallelTransform; 109 | 110 | return TransformTestClass; 111 | } 112 | }; 113 | 114 | export default helpers; 115 | -------------------------------------------------------------------------------- /src/lib/parallel-transform.js: -------------------------------------------------------------------------------- 1 | import stream from 'stream'; 2 | import cyclist from 'cyclist'; 3 | 4 | /** 5 | * ParallelTransform instance 6 | * All child classes must implement the `_parallelTransform` function. 7 | * Child class should not implement the `_transform` and `_flush` functions. 8 | * 9 | * @param {Number} maxParallel The maximum number of 10 | * simulatenous transformations 11 | * @param {Object} options Options which will be passed 12 | * to the `stream.Transform` constructor 13 | **/ 14 | export default class ParallelTransform extends stream.Transform { 15 | constructor(maxParallel = 1, options = {}) { 16 | options.highWaterMark = options.highWaterMark || Math.max(maxParallel, 16); 17 | 18 | super(options); 19 | 20 | this.maxParallel = maxParallel; 21 | this.destroyed = false; 22 | this.flushed = false; 23 | this.buffer = cyclist(maxParallel); 24 | this.top = 0; 25 | this.bottom = 0; 26 | this.ondrain = null; 27 | } 28 | 29 | /** 30 | * Destroys the stream 31 | * The results of all pending transformations will be discarded 32 | **/ 33 | destroy() { 34 | if (this.destroyed) { 35 | return; 36 | } 37 | 38 | this.destroyed = true; 39 | this.emit('close'); 40 | } 41 | 42 | /** 43 | * Called for every item in the stream 44 | * @param {?} chunk The chunk of data to be transformed 45 | * @param {String} enc Encoding, if it `chunk` is a string 46 | * @param {Function} done Callback to be called when finished 47 | * @return {?} Something to get out 48 | **/ 49 | _transform(chunk, enc, done) { 50 | const pos = this.top++; 51 | 52 | this._parallelTransform(chunk, (err, data) => { // eslint-disable-line no-underscore-dangle, max-len 53 | if (this.destroyed) { 54 | return; 55 | } 56 | 57 | // abort on error 58 | if (err) { 59 | this.emit('error', err); 60 | this.push(null); 61 | this.destroy(); 62 | return; 63 | } 64 | 65 | // insert result into corresponding place in buffer 66 | const result = typeof data === 'undefined' || data === null ? null : data; 67 | this.buffer.put(pos, result); 68 | 69 | // attempt to drain the buffer 70 | this.drain(); 71 | }); 72 | 73 | // immediatelly signal `done` if no more than `maxParallel` results buffered 74 | if (this.top - this.bottom < this.maxParallel) { 75 | return done(); 76 | } 77 | 78 | // otherwise wait until a transformation finished 79 | this.ondrain = done; 80 | return null; 81 | } 82 | 83 | /** 84 | * The _transform method of the parallel transform stream 85 | * This method must be re-implemented by child classes 86 | * @param {?} data Data to be transformed 87 | * @param {Function} done Callback which must be executed 88 | * when transformations have finished 89 | **/ 90 | _parallelTransform(data, done) { // eslint-disable-line no-unused-vars 91 | throw new Error('Not implemented'); 92 | } 93 | 94 | /** 95 | * Called when all items have been processed 96 | * @param {Function} done Callback to signify when done 97 | **/ 98 | _flush(done) { 99 | this.flushed = true; 100 | this.ondrain = done; 101 | this.drain(); 102 | } 103 | 104 | /** 105 | * Fire the `data` event for buffered items, in order 106 | * The buffer will be cleared in such a way that the 107 | * order of the input items is preserved. This means that calling 108 | * `drain` does not necessarily clear the entire buffer, as it will 109 | * have to wait for further results if a transformation has not yet finished 110 | * This function should never be called from outside this class 111 | **/ 112 | drain() { 113 | // clear the buffer until we reach an item who's result has not yet arrived 114 | while (typeof this.buffer.get(this.bottom) !== 'undefined') { 115 | const data = this.buffer.del(this.bottom++); 116 | 117 | if (data === null) { 118 | continue; 119 | } 120 | 121 | this.push(data); 122 | } 123 | 124 | // call `ondrain` if the buffer is drained 125 | if (this.drained() && this.ondrain) { 126 | const ondrain = this.ondrain; 127 | this.ondrain = null; 128 | ondrain(); 129 | } 130 | } 131 | 132 | /** 133 | * Checks whether or not the buffer is drained 134 | * While receiving chunks, the buffer counts as drained as soon as 135 | * no more than `maxParallel` items are buffered. 136 | * When the stream is being flushed, the buffer counts as drained 137 | * if and only if it is entirely empty. 138 | * @return {Boolean} true if drained 139 | **/ 140 | drained() { 141 | var diff = this.top - this.bottom; 142 | return this.flushed ? !diff : diff < this.maxParallel; 143 | } 144 | } 145 | -------------------------------------------------------------------------------- /src/geocoder.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable one-var */ 2 | 3 | import Cache from './cache'; 4 | import isEmpty from 'amp-is-empty'; 5 | import GoogleGeocoder from './lib/google-geocoder'; 6 | import Errors from './errors'; 7 | import defaults from './defaults'; 8 | 9 | const geocoderDefaults = { 10 | clientId: null, 11 | privateKey: null, 12 | apiKey: null, 13 | queriesPerSecond: defaults.defaultQueriesPerSecond, 14 | maxRetries: defaults.maxRetries 15 | }; 16 | 17 | /** 18 | * Validate a Geocoder options object 19 | * This function throws an exception if the options are invalid 20 | * @param {Object} options The options object to be validated 21 | */ 22 | function validateOptions(options) { // eslint-disable-line complexity 23 | if ((options.clientId || options.privateKey) && options.apiKey) { 24 | throw new Error('Can only specify credentials or API key'); 25 | } 26 | 27 | if (options.clientId && !options.privateKey) { 28 | throw new Error('Missing privateKey'); 29 | } 30 | 31 | if (!options.clientId && options.privateKey) { 32 | throw new Error('Missing clientId'); 33 | } 34 | 35 | if (!options.apiKey && !(options.clientId && options.privateKey)) { 36 | throw new Error('Must either provide credentials or API key'); 37 | } 38 | 39 | if (options.queriesPerSecond < defaults.minQueriesPerSecond || 40 | options.queriesPerSecond > defaults.maxQueriesPerSecond 41 | ) { 42 | throw new Error('Requests per second must be >= 1 and <= 50'); 43 | } 44 | } 45 | 46 | /** 47 | * Geocoder instance 48 | * @type {Class} 49 | * @param {Object} options The options for the Geocoder 50 | */ 51 | export default class Geocoder { 52 | /** 53 | * Constructs a geocoder. 54 | * @param {Object} options Geocoder options. 55 | * @param {Object} geocoder The Google geocoding API class 56 | * @param {Object} GeoCache The Cache class 57 | */ 58 | constructor(options = {}, geocoder = GoogleGeocoder, GeoCache = Cache) { 59 | options = Object.assign({}, geocoderDefaults, options); 60 | validateOptions(options); 61 | 62 | this.queriesPerSecond = options.queriesPerSecond; 63 | this.maxRetries = options.maxRetries; 64 | this.queries = -1; 65 | this.queue = []; 66 | 67 | this.cache = new GeoCache(options.cacheFile); 68 | this.geocoder = geocoder.init({ 69 | google_client_id: options.clientId, // eslint-disable-line camelcase 70 | google_private_key: options.privateKey, // eslint-disable-line camelcase 71 | key: options.apiKey 72 | }); 73 | } 74 | 75 | /** 76 | * Geocode a single address 77 | * @param {String} address The address to geocode 78 | * @return {Promise} The promise 79 | */ 80 | geocodeAddress(address) { 81 | return new Promise((resolve, reject) => { 82 | this.queueGeocode(address, resolve, reject); 83 | }); 84 | } 85 | 86 | /** 87 | * Add a geocoding operation to the queue of geocodes 88 | * @param {String} address The address to geocode 89 | * @param {Function} resolve The Promise resolve function 90 | * @param {Function} reject The Promise reject function 91 | * @param {Number} retries The number of times this query has been tried 92 | * @return {?} Something to get out 93 | */ 94 | queueGeocode(address, resolve, reject, retries = 0) { 95 | const cachedAddress = this.cache.get(address); 96 | if (cachedAddress) { 97 | return resolve(cachedAddress); 98 | } 99 | 100 | if (this.queries === -1) { 101 | this.startBucket(); 102 | } else if (this.queries >= this.queriesPerSecond) { 103 | // maximum number of queries for this bucket exceeded 104 | return this.queue.push([address, resolve, reject, retries]); 105 | } 106 | 107 | this.queries++; 108 | this.startGeocode(address, resolve, reject, retries); 109 | return null; 110 | } 111 | 112 | /** 113 | * Reset query count and start a timeout of 1 second for this bucket 114 | **/ 115 | startBucket() { 116 | setTimeout(() => { 117 | this.queries = -1; 118 | this.drainQueue(); 119 | }, defaults.bucketDuration); 120 | 121 | this.queries = 0; 122 | } 123 | 124 | /** 125 | * Geocode the first `queriesPerSecond` items from the queue 126 | **/ 127 | drainQueue() { 128 | this.queue 129 | .splice(0, this.queriesPerSecond) 130 | .forEach(query => { 131 | this.queueGeocode(...query); 132 | }); 133 | } 134 | 135 | /** 136 | * Start geocoding a single address 137 | * @param {String} address The address to geocode 138 | * @param {Function} resolve The Promise resolve function 139 | * @param {Function} reject The Promise reject function 140 | * @param {Number} retries The number of times this query has been tried 141 | */ 142 | startGeocode(address, resolve, reject, retries = 0) { 143 | const geoCodeParams = { 144 | address: address.replace('\'', '') 145 | }; 146 | 147 | this.geocoder.geocode(geoCodeParams, (error, response) => { 148 | if (error) { 149 | const errorMessage = Errors[error.code] || 150 | 'Google Maps API error: ' + error.code; 151 | 152 | return reject(new Error(errorMessage)); 153 | } 154 | 155 | if (response.status === 'OVER_QUERY_LIMIT') { 156 | if (retries >= this.maxRetries) { 157 | return reject(new Error('Over query limit')); 158 | } 159 | 160 | return this.queueGeocode(address, resolve, reject, retries + 1); 161 | } 162 | 163 | if (isEmpty(response.results)) { 164 | return reject(new Error('No results found')); 165 | } 166 | 167 | const results = response.results; 168 | 169 | this.cache.add(address, results); 170 | return resolve(results); 171 | }); 172 | } 173 | } 174 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "node": true, 5 | "mocha": true, 6 | "es6": true 7 | }, 8 | "globals": { 9 | "google": true, 10 | "cartodb": true 11 | }, 12 | "parserOptions": { 13 | "sourceType": "module" 14 | }, 15 | "ecmaFeatures": { 16 | "arrowFunctions": true, 17 | "binaryLiterals": true, 18 | "blockBindings": true, 19 | "classes": true, 20 | "defaultParams": true, 21 | "destructuring": true, 22 | "forOf": true, 23 | "generators": true, 24 | "modules": true, 25 | "objectLiteralComputedProperties": true, 26 | "objectLiteralDuplicateProperties": true, 27 | "objectLiteralShorthandMethods": true, 28 | "objectLiteralShorthandProperties": true, 29 | "octalLiterals": true, 30 | "regexUFlag": true, 31 | "regexYFlag": true, 32 | "restParams": true, 33 | "spread": true, 34 | "superInFunctions": true, 35 | "templateStrings": true, 36 | "unicodeCodePointEscapes": true, 37 | "globalReturn": true, 38 | "jsx": false 39 | }, 40 | "rules": { 41 | "comma-dangle": 2, 42 | "no-cond-assign": [2, "except-parens"], 43 | "no-console": 1, 44 | "no-constant-condition": 2, 45 | "no-control-regex": 2, 46 | "no-debugger": 1, 47 | "no-dupe-keys": 2, 48 | "no-empty": 2, 49 | "no-empty-character-class": 2, 50 | "no-ex-assign": 2, 51 | "no-extra-boolean-cast": 2, 52 | "no-extra-parens": 1, 53 | "no-extra-semi": 2, 54 | "no-func-assign": 2, 55 | "no-inner-declarations": 2, 56 | "no-invalid-regexp": 2, 57 | "no-irregular-whitespace": 2, 58 | "no-obj-calls": 2, 59 | "no-regex-spaces": 2, 60 | "no-sparse-arrays": 2, 61 | "no-unreachable": 2, 62 | "use-isnan": 2, 63 | "valid-jsdoc": [2, { 64 | "requireReturn": false, 65 | "requireReturnDescription": false 66 | }], 67 | "valid-typeof": 2, 68 | 69 | "block-scoped-var": 2, 70 | "complexity": [1, 7], 71 | "consistent-return": 2, 72 | "curly": [2, "all"], 73 | "default-case": 2, 74 | "dot-notation": [2, { 75 | "allowKeywords": true 76 | }], 77 | "eqeqeq": 2, 78 | "guard-for-in": 1, 79 | "no-alert": 1, 80 | "no-caller": 2, 81 | "no-div-regex": 1, 82 | "no-else-return": 2, 83 | "no-eq-null": 2, 84 | "no-eval": 2, 85 | "no-extend-native": 2, 86 | "no-extra-bind": 2, 87 | "no-fallthrough": 2, 88 | "no-floating-decimal": 2, 89 | "no-implicit-coercion": [2, { 90 | "boolean": true, 91 | "number": true, 92 | "string": true 93 | }], 94 | "no-implied-eval": 2, 95 | "no-invalid-this": 2, 96 | "no-iterator": 2, 97 | "no-labels": 2, 98 | "no-lone-blocks": 2, 99 | "no-loop-func": 2, 100 | "no-multi-spaces": 2, 101 | "no-multi-str": 2, 102 | "no-native-reassign": 2, 103 | "no-new": 2, 104 | "no-new-func": 2, 105 | "no-new-wrappers": 2, 106 | "no-octal": 2, 107 | "no-octal-escape": 2, 108 | "no-process-env": 1, 109 | "no-proto": 2, 110 | "no-redeclare": 2, 111 | "no-return-assign": 2, 112 | "no-script-url": 2, 113 | "no-self-compare": 2, 114 | "no-sequences": 2, 115 | "no-unused-expressions": 2, 116 | "no-useless-call": 2, 117 | "no-void": 2, 118 | "no-warning-comments": 1, 119 | "no-with": 2, 120 | "radix": 2, 121 | "vars-on-top": 0, 122 | "wrap-iife": [2, "inside"], 123 | "yoda": [2, "never"], 124 | 125 | "strict": [0, "never"], 126 | 127 | "init-declarations": 0, 128 | "no-catch-shadow": 2, 129 | "no-delete-var": 2, 130 | "no-label-var": 2, 131 | "no-shadow": 2, 132 | "no-shadow-restricted-names": 2, 133 | "no-undef": 2, 134 | "no-undef-init": 2, 135 | "no-undefined": 2, 136 | "no-unused-vars": [2, { 137 | "vars": "all", 138 | "args": "after-used" 139 | }], 140 | "no-use-before-define": [2, "nofunc"], 141 | 142 | "callback-return": 2, 143 | "handle-callback-err": 1, 144 | "no-mixed-requires": 0, 145 | "no-new-require": 2, 146 | "no-path-concat": 2, 147 | "no-process-exit": 1, 148 | "no-restricted-modules": 0, 149 | "no-sync": 0, 150 | 151 | "indent": [2, 2, {"SwitchCase": 1}], 152 | "brace-style": [2, "1tbs", { 153 | "allowSingleLine": false 154 | }], 155 | "camelcase": 2, 156 | "comma-spacing": [2, { 157 | "before": false, 158 | "after": true 159 | }], 160 | "comma-style": [2, "last"], 161 | "consistent-this": [2, "none"], 162 | "eol-last": 1, 163 | "func-names": 0, 164 | "func-style": 0, 165 | "id-length": 0, 166 | "key-spacing": [2, { 167 | "beforeColon": false, 168 | "afterColon": true 169 | }], 170 | "max-nested-callbacks": [2, 3], 171 | "new-cap": 2, 172 | "new-parens": 2, 173 | "no-array-constructor": 2, 174 | "no-inline-comments": 0, 175 | "no-lonely-if": 2, 176 | "no-mixed-spaces-and-tabs": 2, 177 | "no-multiple-empty-lines": [1, { 178 | "max": 1 179 | }], 180 | "no-nested-ternary": 2, 181 | "no-new-object": 2, 182 | "no-spaced-func": 2, 183 | "no-ternary": 0, 184 | "no-trailing-spaces": 2, 185 | "no-underscore-dangle": 1, 186 | "one-var": 0, 187 | "operator-assignment": 0, 188 | "padded-blocks": [1, "never"], 189 | "quote-props": [0, "as-needed", { 190 | "keywords": false 191 | }], 192 | "quotes": [2, "single", "avoid-escape"], 193 | "semi": [2, "always"], 194 | "semi-spacing": [2, { 195 | "before": false, 196 | "after": true 197 | }], 198 | "sort-vars": 0, 199 | "space-before-function-paren": [2, "never"], 200 | "space-before-blocks": [2, "always"], 201 | "space-in-parens": [2, "never"], 202 | "space-infix-ops": 2, 203 | "space-unary-ops": [2, { 204 | "words": true, 205 | "nonwords": false 206 | }], 207 | "spaced-comment": [2, "always"], 208 | "object-curly-spacing": [1, "never"], 209 | "array-bracket-spacing": [1, "never"], 210 | "wrap-regex": 0, 211 | 212 | "no-var": 0, 213 | "generator-star-spacing": [2, { 214 | "before": true, 215 | "after": false 216 | }], 217 | "keyword-spacing": 2, 218 | "max-depth": [1, 3], 219 | "max-len": [2, 80, 2], 220 | "max-params": [2, 4], 221 | "max-statements": [1, 15], 222 | "no-bitwise": 2, 223 | "no-plusplus": 0, 224 | "arrow-parens": [1, "as-needed"], 225 | "arrow-spacing": [2, { 226 | "before": true, 227 | "after": true 228 | }], 229 | "no-class-assign": 2, 230 | "no-const-assign": 2, 231 | "prefer-reflect": 1, 232 | "prefer-spread": 1, 233 | "require-yield": 2 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /test/index-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions, one-var */ 2 | import stream from 'stream'; 3 | import intoStream from 'into-stream'; 4 | 5 | import should from 'should'; 6 | import sinon from 'sinon'; 7 | import streamAssert from 'stream-assert'; 8 | 9 | import GeoBatch from '../src/index.js'; 10 | import {getGeocoderOptions} from './lib/helpers.js'; 11 | import ParallelTransform from '../src/lib/parallel-transform'; 12 | 13 | describe('Testing GeoBatch', () => { 14 | it('should accept a clientId and a privateKey', function() { 15 | /* eslint-disable no-unused-vars */ 16 | const MockGeoCoder = sinon.stub(), 17 | expectedOptions = getGeocoderOptions({apiKey: null}), 18 | options = {clientId: 'a clientID', privateKey: 'a privateKey'}, 19 | geoBatch = new GeoBatch(options, MockGeoCoder); 20 | 21 | expectedOptions.clientId = 'a clientID'; 22 | expectedOptions.privateKey = 'a privateKey'; 23 | 24 | sinon.assert.calledWith(MockGeoCoder, expectedOptions); 25 | }); 26 | 27 | it('should accept an apiKey', function() { 28 | /* eslint-disable no-unused-vars */ 29 | const MockGeoCoder = sinon.stub(), 30 | options = getGeocoderOptions({apiKey: 'dummy'}), 31 | expectedOptions = options, 32 | geoBatch = new GeoBatch(options, MockGeoCoder); 33 | 34 | sinon.assert.calledWith(MockGeoCoder, expectedOptions); 35 | }); 36 | 37 | it('should accept a number of maximum queries per second', function() { 38 | /* eslint-disable no-unused-vars */ 39 | const MockGeoCoder = sinon.stub(), 40 | options = getGeocoderOptions({queriesPerSecond: 25}), 41 | expectedOptions = options, 42 | geoBatch = new GeoBatch(options, MockGeoCoder); 43 | 44 | sinon.assert.calledWith(MockGeoCoder, expectedOptions); 45 | }); 46 | 47 | it('should accept a number of maximum retries', function() { 48 | /* eslint-disable no-unused-vars */ 49 | const MockGeoCoder = sinon.stub(), 50 | options = getGeocoderOptions({maxRetries: 3}), 51 | expectedOptions = options, 52 | geoBatch = new GeoBatch(options, MockGeoCoder); 53 | 54 | sinon.assert.calledWith(MockGeoCoder, expectedOptions); 55 | }); 56 | 57 | it('should accept an accessor function', function() { 58 | /* eslint-disable no-unused-vars */ 59 | const mockAccessor = sinon.stub(), 60 | geoBatch = new GeoBatch(getGeocoderOptions({ 61 | accessor: mockAccessor 62 | })); 63 | 64 | should(geoBatch.accessor).be.equal(mockAccessor); 65 | }); 66 | 67 | it('should have a geocode function that accepts and returns a stream', 68 | function(done) { 69 | const geoBatch = new GeoBatch(getGeocoderOptions()); 70 | 71 | should(geoBatch.geocode).be.a.Function; 72 | 73 | geoBatch.geocode([]) 74 | .on('data', function() {}) 75 | .on('end', function() { 76 | done(); 77 | }); 78 | } 79 | ); 80 | 81 | it('should call geocodeStream with correct stats when called with array', 82 | () => { 83 | const geoBatch = new GeoBatch(getGeocoderOptions()), 84 | geocodeStreamFunction = sinon.stub(), 85 | mockAddressArray = ['mock address'], 86 | expectedTotal = mockAddressArray.length, 87 | expectedCurrent = 0; 88 | geoBatch.geocodeStream = geocodeStreamFunction; 89 | 90 | geoBatch.geocode(mockAddressArray); 91 | const argumentsStats = geocodeStreamFunction.args[0][1]; 92 | 93 | should(argumentsStats.total).equal(expectedTotal); 94 | should(argumentsStats.current).equal(expectedCurrent); 95 | should(argumentsStats.startTime).be.instanceof(Date); 96 | } 97 | ); 98 | 99 | it('should transform an array to stream and pass it to geocodeStream', () => { 100 | const geoBatch = new GeoBatch(getGeocoderOptions()), 101 | geocodeStreamFunction = sinon.stub(), 102 | mockAddressArray = ['mock address'], 103 | expectedTotal = mockAddressArray.length, 104 | expectedCurrent = 0; 105 | geoBatch.geocodeStream = geocodeStreamFunction; 106 | 107 | geoBatch.geocode(mockAddressArray); 108 | const inputStream = geocodeStreamFunction.args[0][0]; 109 | // Check for instance of stream. 110 | should(inputStream).be.instanceof(stream); 111 | 112 | // Check if first element of stream is equal to input. 113 | inputStream 114 | .pipe(streamAssert.first(mockAddressArray[0])); 115 | }); 116 | 117 | it('should accept a stream into geocode and pass it on', () => { 118 | const geoBatch = new GeoBatch(getGeocoderOptions()), 119 | geocodeStreamFunction = sinon.stub(), 120 | mockInputStream = intoStream.obj(['mock address']); 121 | geoBatch.geocodeStream = geocodeStreamFunction; 122 | 123 | geoBatch.geocode(mockInputStream); 124 | sinon.assert.calledWith(geocodeStreamFunction, mockInputStream); 125 | }); 126 | 127 | it('geocodeStream should pipe geocoder stream', done => { 128 | // Create a mock geocode-stream class that passes elements unchanged. 129 | class mockGeocodeStream extends stream.Transform { 130 | constructor() { 131 | super({objectMode: true}); 132 | } 133 | _transform(item, encoding, done) { // eslint-disable-line 134 | this.push(item); 135 | done(); 136 | } 137 | } 138 | const mockGeoCoder = sinon.stub(), 139 | geoBatch = new GeoBatch(getGeocoderOptions(), 140 | mockGeoCoder, 141 | mockGeocodeStream), 142 | mockAddress = 'some address', 143 | input = intoStream.obj([mockAddress]), 144 | resultStream = geoBatch.geocodeStream(input); 145 | 146 | resultStream 147 | .pipe(streamAssert.first(item => { 148 | should(item).equal(mockAddress); 149 | })) 150 | .pipe(streamAssert.end(error => { 151 | done(error); 152 | })); 153 | }); 154 | 155 | it('geocodeStream should pipe geocoder stream', done => { 156 | const mockAccessor = sinon.stub(); 157 | 158 | // Create a mock geocode-stream class that passes elements unchanged. 159 | class mockGeocodeStream extends ParallelTransform { 160 | constructor(geocoder, queriesPerSecond, stats, accessor) { 161 | super(queriesPerSecond, {objectMode: true}); 162 | should(accessor).equal(mockAccessor); 163 | done(); 164 | } 165 | _parallelTransform(item, done) { // eslint-disable-line no-shadow 166 | done(null, item); 167 | } 168 | } 169 | const mockGeoCoder = sinon.stub(), 170 | geoBatch = new GeoBatch( 171 | getGeocoderOptions({ 172 | accessor: mockAccessor 173 | }), 174 | mockGeoCoder, 175 | mockGeocodeStream 176 | ), 177 | mockAddress = 'some address', 178 | input = intoStream.obj([mockAddress]), 179 | resultStream = geoBatch.geocodeStream(input); 180 | 181 | resultStream 182 | .pipe(streamAssert.first(item => { 183 | should(item).equal(mockAddress); 184 | })); 185 | }); 186 | }); 187 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # node-geobatch 2 | 3 | [![Build Status](https://travis-ci.org/ubilabs/node-geobatch.svg?branch=master)](https://travis-ci.org/ubilabs/node-geobatch) [![npm version](https://badge.fury.io/js/geobatch.svg)](http://badge.fury.io/js/geobatch) 4 | 5 | [![forthebadge](http://forthebadge.com/images/badges/uses-badges.svg)](http://forthebadge.com) 6 | 7 | Batch geocode addresses from multiple sources. It limits the calls to not run in `OVER_QUERY_LIMIT` by the Google Maps API. Results are cached locally to prevent duplicate geocoding requests. 8 | 9 | ## Usage 10 | 11 | Install GeoBatch: 12 | 13 | ```sh 14 | npm install geobatch --save 15 | ``` 16 | 17 | Then include and use it in your project: 18 | 19 | ```js 20 | var GeoBatch = require('geobatch'), 21 | geoBatch = new GeoBatch(); 22 | 23 | geoBatch.geocode(['Hamburg', 'Berlin']) 24 | .on('data', function(data) { 25 | console.log('Geocode result', data); 26 | }) 27 | .on('end', function() { 28 | console.log('Finished!'); 29 | }); 30 | ``` 31 | 32 | `geoBatch.geocode` also accepts a stream as input. 33 | 34 | ### Result 35 | 36 | The data in the example above is an object like this: 37 | 38 | ```js 39 | { 40 | error: null, 41 | address: 'Hamburg', 42 | input: 'Hamburg', 43 | location: { lat: 53.5510846, lng: 9.9936818 }, 44 | result: { 45 | address_components: [ … ], 46 | formatted_address: 'Hamburg, Germany', 47 | geometry: { 48 | bounds: { northeast: [Object], southwest: [Object] }, 49 | location: { lat: 53.5510846, lng: 9.9936818 }, 50 | location_type: 'APPROXIMATE', 51 | viewport: { northeast: [Object], southwest: [Object] } }, 52 | place_id: 'ChIJuRMYfoNhsUcRoDrWe_I9JgQ', 53 | types: [ 'locality', 'political' ] 54 | } 55 | }, 56 | results: [{ 57 | address_components: [ … ], 58 | formatted_address: 'Hamburg, Germany', 59 | geometry: { 60 | bounds: { northeast: [Object], southwest: [Object] }, 61 | location: { lat: 53.5510846, lng: 9.9936818 }, 62 | location_type: 'APPROXIMATE', 63 | viewport: { northeast: [Object], southwest: [Object] } }, 64 | place_id: 'ChIJuRMYfoNhsUcRoDrWe_I9JgQ', 65 | types: [ 'locality', 'political' ] 66 | }] 67 | }, 68 | total: 2, 69 | current: 1, 70 | pending: 1, 71 | percent: 50, 72 | estimatedDuration: 189 73 | } 74 | ``` 75 | 76 | #### `error` 77 | 78 | Type `string`. Contains the error message. Default `null`. 79 | 80 | #### `address` 81 | 82 | Type `string`. Contains the address that was put into the geocoder. Default `''`. 83 | 84 | #### `input` 85 | 86 | Contains the original input. This was the input to the accessor function to get the `address`. 87 | 88 | #### `location` 89 | 90 | Type `Object`. The coordinates returned from the Google Maps Geocoding API. Default `{}`. 91 | 92 | #### `result` 93 | 94 | Type `Object`. The complete first result item from the Google Maps Geocoding API. Default `{}`. 95 | 96 | #### `results` 97 | 98 | Type `Array`. The complete result from the Google Maps Geocoding API. 99 | 100 | #### `total` 101 | 102 | Type `Number`. The total number of addresses to geocode. (Not included if stream was provided.) 103 | 104 | #### `current` 105 | 106 | Type `Number`. The index of the current geocoded address. 107 | 108 | #### `pending` 109 | 110 | Type `Number`. The number of addresses to still geocode. (Not included if stream was provided.) 111 | 112 | #### `percent` 113 | 114 | Type `Number`. The percentage that got geocoded already. (Not included if stream was provided.) 115 | 116 | #### `estimatedDuration` 117 | 118 | Type `Number`. The estimated duration based on past progress. In milliseconds. (Not included if stream was provided.) 119 | 120 | ### Options 121 | 122 | You can pass in options on initalization of the GeoBatch: 123 | 124 | ```js 125 | new GeoBatch({ 126 | clientId: 'myClientId', 127 | privateKey: 'myPrivateKey', 128 | apiKey: 'myApiKey', 129 | cacheFile: 'myGeocache.db', 130 | accessor: myAccessorFunction, 131 | maxRetries: 1, 132 | queriesPerSecond: 50 133 | }); 134 | ``` 135 | 136 | #### `clientId` 137 | 138 | Type `String`. The Google Maps Client ID, if you are using Google for Work. If this is passed in, the `privateKey` is also required. 139 | You must provide either a `clientId` and `privateKey`, or an `apiKey`. Default is `null`. 140 | 141 | #### `privateKey` 142 | 143 | Type `String`. The Google Maps private key, if you are using Google for Work. If this is passed in, the `clientId` is also required. Default is `null`. 144 | 145 | #### `apiKey` 146 | 147 | Type `String`. The Google Maps API key. If this is passed, neither `clientId` nor `privateKey` may be set. 148 | You must provide either a `clientId` and `privateKey`, or an `apiKey`. 149 | Default is `null`. 150 | 151 | #### `cacheFile` 152 | 153 | Type `String`. The path of the cache file, in which the geocoding responses are cached. Default is `geocache.db`. 154 | 155 | #### `accessor` 156 | 157 | Type `Function`. An accessor function that extracts the address data from the provided input. Defaults to: 158 | ```js 159 | function(address) { 160 | return address; 161 | } 162 | ``` 163 | 164 | #### `maxRetries` 165 | 166 | Type `Number`. This option defines how often Geobatch will retry geocoding if the query limit was exceeded. 167 | Default is `0`. 168 | 169 | #### `queriesPerSecond` 170 | 171 | Type `Number`. The maximum number of requests per second. This number must be between 1 and 50 (inclusive). 172 | Please note that due to varying network latency the maximum value of 50 QPS will result in occasional `OVER_QUERY_LIMIT` errors. 173 | Use a moderate value (the default is safe), or the `maxRetries` option. 174 | Default is `35`. 175 | 176 | ## Contribution 177 | 178 | **Make sure to follow the [Git Commit Conventions](https://github.com/ubilabs/node-geobatch/blob/master/CONVENTIONS.md)! Test and lint the code and adapt to the code style used in the project.** 179 | 180 | Clone the repository and run: 181 | 182 | ```sh 183 | npm install 184 | ``` 185 | 186 | To run the tests, run: 187 | 188 | ``` 189 | npm test 190 | ``` 191 | 192 | To make a release, run: 193 | 194 | ``` 195 | npm run release patch|minor|major 196 | ``` 197 | 198 | ## License (MIT) 199 | 200 | Copyright © 2015 Ubilabs GmbH 201 | 202 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the “Software”), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: 203 | 204 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. 205 | 206 | THE SOFTWARE IS PROVIDED “AS IS”, WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 207 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | 2 | ## [1.4.3](https://github.com/ubilabs/node-geobatch/compare/1.4.2...v1.4.3) (2016-07-26) 3 | 4 | 5 | ### Bug Fixes 6 | 7 | * **geocode-stream:** ensure sequential 'current' field ([7be819d](https://github.com/ubilabs/node-geobatch/commit/7be819d)) 8 | * **geocoder:** use correct client ID / private key parameter ([2d72860](https://github.com/ubilabs/node-geobatch/commit/2d72860)) 9 | 10 | 11 | 12 | 13 | ## [1.4.2](https://github.com/ubilabs/node-geobatch/compare/1.4.1...v1.4.2) (2016-07-14) 14 | 15 | 16 | ### Bug Fixes 17 | 18 | * **geocode-stream:** fix a bug with low QPS limits ([48fe3fd](https://github.com/ubilabs/node-geobatch/commit/48fe3fd)) 19 | 20 | 21 | ### 1.4.1 (2016-07-07) 22 | 23 | 24 | ## 1.4.0 (2016-07-07) 25 | 26 | 27 | #### Features 28 | 29 | * **geobatch:** 30 | * add `maxRetries` option ([3df0a6ab](https://github.com/ubilabs/node-geobatch/commit/3df0a6abaa703e0135b76294b56c2006ad8ae534)) 31 | * add an option to specify QPS ([1782a232](https://github.com/ubilabs/node-geobatch/commit/1782a232388330370a4fd01695e191215a1d36b6)) 32 | * **geocoder:** 33 | * retry once after running into query limit ([d776d830](https://github.com/ubilabs/node-geobatch/commit/d776d83000258e8a50811b73ac1099a449ff129d)) 34 | * implement parallel processing to leverage full QPS ([bd97c519](https://github.com/ubilabs/node-geobatch/commit/bd97c5195fb20f7cf76128ff50e48f535046bb82)) 35 | 36 | 37 | ## 1.3.0 (2016-06-28) 38 | 39 | 40 | #### Bug Fixes 41 | 42 | * **geocoder:** 43 | * require api key or client id / private key option ([6790293d](https://github.com/ubilabs/node-geobatch/commit/6790293d9180d24f744f50792600ce77737d7d5a)) 44 | * increase queries per second when authenticated ([3acceafe](https://github.com/ubilabs/node-geobatch/commit/3acceafe479cd6617e355f0506d285108c09fb4a)) 45 | * return correct error messages ([90f09bbc](https://github.com/ubilabs/node-geobatch/commit/90f09bbc5f7040ad1e1148f71007ce99e726f162)) 46 | * **npm-scripts:** remove `./node_modules/.bin/` from modules call ([bab003df](https://github.com/ubilabs/node-geobatch/commit/bab003df3c0d22a8e891d960e8cf44e020349264)) 47 | * **test:** use babel-core as mocha compilers plugin for testing ([54063f16](https://github.com/ubilabs/node-geobatch/commit/54063f1609e34c2fc25580cb77ba31486656a674)) 48 | * **tests:** fix rebase bug ([d5a58e14](https://github.com/ubilabs/node-geobatch/commit/d5a58e14ee0943b5fefec7f4756bd11d4cb6338a)) 49 | 50 | 51 | #### Features 52 | 53 | * **GeoBatch:** make geocode accept streams ([0a032e74](https://github.com/ubilabs/node-geobatch/commit/0a032e74b3f1655ce7f5bec09461efdc46a3cc42)) 54 | * **geobatch:** take accessor function for address ([7588f2ca](https://github.com/ubilabs/node-geobatch/commit/7588f2ca9e3eb23c40506b658a8be6ff910adaa1)) 55 | * **geocode stream:** handle stats if input is stream ([f2cdbd89](https://github.com/ubilabs/node-geobatch/commit/f2cdbd892bd48c1294237a18ec56ff0dbaa8187c)) 56 | * **geocoder:** 57 | * allow authentication via api key ([c175b492](https://github.com/ubilabs/node-geobatch/commit/c175b492ac2578a145f8441db760de37d748cde6)) 58 | * return full geocoding result ([ec20a890](https://github.com/ubilabs/node-geobatch/commit/ec20a8908cfec55c8ec50dfe11b2265649bebee2)) 59 | * **geocodestream:** add full input and full result to output stream ([9bd0630c](https://github.com/ubilabs/node-geobatch/commit/9bd0630cd2910695725f0aa954d0871ef2b5622e)) 60 | 61 | 62 | ## 1.2.0 (2015-10-13) 63 | 64 | 65 | ### 1.1.1 (2015-10-13) 66 | 67 | 68 | #### Bug Fixes 69 | 70 | * **npm-scripts:** remove `./node_modules/.bin/` from modules call ([bab003df](https://github.com/ubilabs/node-geobatch/commit/bab003df3c0d22a8e891d960e8cf44e020349264)) 71 | * **test:** use babel-core as mocha compilers plugin for testing ([54063f16](https://github.com/ubilabs/node-geobatch/commit/54063f1609e34c2fc25580cb77ba31486656a674)) 72 | * **tests:** fix rebase bug ([d5a58e14](https://github.com/ubilabs/node-geobatch/commit/d5a58e14ee0943b5fefec7f4756bd11d4cb6338a)) 73 | 74 | 75 | #### Features 76 | 77 | * **GeoBatch:** make geocode accept streams ([0a032e74](https://github.com/ubilabs/node-geobatch/commit/0a032e74b3f1655ce7f5bec09461efdc46a3cc42)) 78 | * **geobatch:** take accessor function for address ([7588f2ca](https://github.com/ubilabs/node-geobatch/commit/7588f2ca9e3eb23c40506b658a8be6ff910adaa1)) 79 | * **geocode stream:** handle stats if input is stream ([f2cdbd89](https://github.com/ubilabs/node-geobatch/commit/f2cdbd892bd48c1294237a18ec56ff0dbaa8187c)) 80 | * **geocoder:** return full geocoding result ([ec20a890](https://github.com/ubilabs/node-geobatch/commit/ec20a8908cfec55c8ec50dfe11b2265649bebee2)) 81 | * **geocodestream:** add full input and full result to output stream ([9bd0630c](https://github.com/ubilabs/node-geobatch/commit/9bd0630cd2910695725f0aa954d0871ef2b5622e)) 82 | 83 | 84 | ## 1.1.0 (2015-10-13) 85 | 86 | 87 | #### Bug Fixes 88 | 89 | * **npm-scripts:** remove `./node_modules/.bin/` from modules call ([bab003df](https://github.com/ubilabs/node-geobatch/commit/bab003df3c0d22a8e891d960e8cf44e020349264)) 90 | * **test:** use babel-core as mocha compilers plugin for testing ([54063f16](https://github.com/ubilabs/node-geobatch/commit/54063f1609e34c2fc25580cb77ba31486656a674)) 91 | * **tests:** fix rebase bug ([d5a58e14](https://github.com/ubilabs/node-geobatch/commit/d5a58e14ee0943b5fefec7f4756bd11d4cb6338a)) 92 | 93 | 94 | #### Features 95 | 96 | * **GeoBatch:** make geocode accept streams ([0a032e74](https://github.com/ubilabs/node-geobatch/commit/0a032e74b3f1655ce7f5bec09461efdc46a3cc42)) 97 | * **geobatch:** take accessor function for address ([7588f2ca](https://github.com/ubilabs/node-geobatch/commit/7588f2ca9e3eb23c40506b658a8be6ff910adaa1)) 98 | * **geocode stream:** handle stats if input is stream ([f2cdbd89](https://github.com/ubilabs/node-geobatch/commit/f2cdbd892bd48c1294237a18ec56ff0dbaa8187c)) 99 | * **geocoder:** return full geocoding result ([ec20a890](https://github.com/ubilabs/node-geobatch/commit/ec20a8908cfec55c8ec50dfe11b2265649bebee2)) 100 | * **geocodestream:** add full input and full result to output stream ([9bd0630c](https://github.com/ubilabs/node-geobatch/commit/9bd0630cd2910695725f0aa954d0871ef2b5622e)) 101 | 102 | 103 | ## 1.1.0 (2015-03-10) 104 | 105 | 106 | #### Bug Fixes 107 | 108 | * **geocoder:** 109 | * do not change address ([f16f9e4f](https://github.com/ubilabs/node-geobatch/commit/f16f9e4f97ee4484d954f8570b6eb4dbe851eda3)) 110 | * do not manipulate original address ([e0d1eca0](https://github.com/ubilabs/node-geobatch/commit/e0d1eca0b8c8e5d2fc70f784ac5fca28eaf21177)) 111 | 112 | 113 | #### Features 114 | 115 | * **cache:** remove default values for location and address ([58c27aa2](https://github.com/ubilabs/node-geobatch/commit/58c27aa2b8950bb9ca8c258b1f0005255db7e1c8)) 116 | * **geocoder:** check cache repeatedly before geocoding requests ([35baec7b](https://github.com/ubilabs/node-geobatch/commit/35baec7bdc3c3fcaafca0fb95fe93572822dd017)) 117 | * **index:** 118 | * also return complete Maps API result on geocode ([87407bc0](https://github.com/ubilabs/node-geobatch/commit/87407bc0c9b32929f0e686b8a0fec244bb002f20)) 119 | * return complete Google Maps geocoding result ([4efe2c44](https://github.com/ubilabs/node-geobatch/commit/4efe2c44596a7fa199473631a65620385127370a)) 120 | 121 | 122 | ### 1.0.2 (2015-03-10) 123 | 124 | 125 | #### Bug Fixes 126 | 127 | * **all:** switch from traceur to babel for ES6 ([b882beeb](https://github.com/ubilabs/node-geobatch/commit/b882beeb349a157541d1f558385a74e79a3d2a00)) 128 | 129 | 130 | ### 1.0.1 (2015-03-09) 131 | 132 | 133 | ## 1.0.0 (2015-03-09) 134 | 135 | 136 | #### Features 137 | 138 | * **cache:** 139 | * add and get values ([6027eca2](https://github.com/ubilabs/node-geobatch/commit/6027eca2df807de1623fe3ca8d99f2ddcdcd461e)) 140 | * add simple cache ([513a7201](https://github.com/ubilabs/node-geobatch/commit/513a72012bd7a5150b45bdf6f575c178381c7dc1)) 141 | * **geocoder:** 142 | * limit API calls according to not run in query limits ([924763ca](https://github.com/ubilabs/node-geobatch/commit/924763ca6a4ea1ffef80a463f56e7eb901422428)) 143 | * return error when reaching query limit ([6e1f66d8](https://github.com/ubilabs/node-geobatch/commit/6e1f66d87cea1cb6e4f929dec480cdf28c5e37d9)) 144 | * catch case when no result is returned ([dc13cdc7](https://github.com/ubilabs/node-geobatch/commit/dc13cdc76383dfd439dd963adcee3254e0630446)) 145 | * return error when not using correct clientId or privateKey ([2d338606](https://github.com/ubilabs/node-geobatch/commit/2d338606e9899b681d26f02073fc2f126354cf9c)) 146 | * reject geocode when not correct clientId or privateKey ([5050f491](https://github.com/ubilabs/node-geobatch/commit/5050f4910996280c3d40cf40d18acf8b8c751031)) 147 | * accept a clientId and privateKey ([06606a36](https://github.com/ubilabs/node-geobatch/commit/06606a366d6c2942e325a38ff6ddcd9d932e1f9c)) 148 | * catch missing clientId or privateKey ([cd498177](https://github.com/ubilabs/node-geobatch/commit/cd498177e76687adc527ac9ecd66aa2b0fd93c48)) 149 | * use cached geocoded addresses ([6958072d](https://github.com/ubilabs/node-geobatch/commit/6958072dadaa910572930c1fe2131d1c5b9ff228)) 150 | * cache geocode requests ([adc98fba](https://github.com/ubilabs/node-geobatch/commit/adc98fba62be1df7fe8c03c8426e182d6d879f5d)) 151 | * geocode an address ([e392d6e3](https://github.com/ubilabs/node-geobatch/commit/e392d6e37fbdebb6bbbeb4a5b2dd9a12ff529429)) 152 | * add basic class ([99f04f9b](https://github.com/ubilabs/node-geobatch/commit/99f04f9badce7d113787c2925b3b761bd121167a)) 153 | * **index:** 154 | * return an empty object on error for location ([3adab5a2](https://github.com/ubilabs/node-geobatch/commit/3adab5a25fe393ed9ea8146196d331cd895e109e)) 155 | * return an error when geocoding fails ([ffc6c23a](https://github.com/ubilabs/node-geobatch/commit/ffc6c23a12c51ebb563606ee8032ecc8595ccd6b)) 156 | * return some meta information on each geocode ([2362b583](https://github.com/ubilabs/node-geobatch/commit/2362b5837501dc260b2346d3d169cac5e7a8b3af)) 157 | * improve and simplify API ([c7dcb256](https://github.com/ubilabs/node-geobatch/commit/c7dcb256c12523ad7e092113e6291cacedce4126)) 158 | * accept and return a stream ([3821ba7b](https://github.com/ubilabs/node-geobatch/commit/3821ba7b2088a2553c4a43e69eabb0cc3ffb9696)) 159 | * return an object with address and location ([e14b773a](https://github.com/ubilabs/node-geobatch/commit/e14b773a7bf904e2810dc207709c8f88acc59760)) 160 | * return an geocoded object via stream ([74dca152](https://github.com/ubilabs/node-geobatch/commit/74dca1525f3303216cae68145212a746e52ac162)) 161 | * use simpler stream implementation ([5aa1fd39](https://github.com/ubilabs/node-geobatch/commit/5aa1fd39590b24c5aa8b703602f39398c0260473)) 162 | * return a stream when calling geocode function ([0a80444b](https://github.com/ubilabs/node-geobatch/commit/0a80444b2b62cae56a6faf4dd20021138c7bde67)) 163 | * add a function geocode ([8d98cb2b](https://github.com/ubilabs/node-geobatch/commit/8d98cb2b3641063432223be1e696f1c7e37ef0d5)) 164 | * accept clientId and privateKey ([a46c16b9](https://github.com/ubilabs/node-geobatch/commit/a46c16b94656b90a1716c6288f6ab8a89a79660e)) 165 | * accept cacheFile param ([33da3c9d](https://github.com/ubilabs/node-geobatch/commit/33da3c9da45b9cee4ed149817d500d1619e5acb6)) 166 | 167 | -------------------------------------------------------------------------------- /test/geocoder-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions, one-var, max-nested-callbacks */ 2 | import should from 'should'; 3 | import Geocoder from '../src/geocoder.js'; 4 | import sinon from 'sinon'; 5 | import { 6 | getGeocodeFunction, 7 | getGeocoderInterface, 8 | getGeocoderOptions 9 | } from './lib/helpers'; 10 | 11 | class MockCache { 12 | constructor() {} 13 | get() {} 14 | add() {} 15 | } 16 | 17 | describe('Testing geocoder', function() { // eslint-disable-line max-statements 18 | it('should require either credentials or an api key', function() { 19 | should(() => { 20 | const geocoder = new Geocoder( // eslint-disable-line no-unused-vars 21 | {}, 22 | getGeocoderInterface(), 23 | MockCache 24 | ); 25 | }).throw('Must either provide credentials or API key'); 26 | }); 27 | 28 | it('should create a cache', function() { 29 | const mackCacheFileName = 'a file name', 30 | mockCacheConstructor = sinon.stub(); 31 | 32 | class NewMockCache extends MockCache { 33 | constructor(fileName) { 34 | super(); 35 | mockCacheConstructor(fileName); 36 | } 37 | } 38 | 39 | const geocoder = new Geocoder( // eslint-disable-line 40 | getGeocoderOptions({ 41 | cacheFile: mackCacheFileName 42 | }), 43 | getGeocoderInterface(), 44 | NewMockCache 45 | ); 46 | 47 | sinon.assert.calledWith(mockCacheConstructor, mackCacheFileName); 48 | }); 49 | 50 | it('should accept a client ID and a private key', function() { 51 | const geocoder = new Geocoder( 52 | {clientId: 'dummy', privateKey: 'dummy'}, 53 | getGeocoderInterface(), 54 | MockCache 55 | ); 56 | 57 | should.exist(geocoder); 58 | }); 59 | 60 | it('should accept an api key', function() { 61 | const geocoder = new Geocoder( 62 | {apiKey: 'dummy'}, 63 | getGeocoderInterface(), 64 | MockCache 65 | ); 66 | 67 | should.exist(geocoder); 68 | }); 69 | 70 | it('should throw an error when there is only the client id', function() { 71 | should(() => { 72 | const geocoder = new Geocoder( // eslint-disable-line 73 | {clientId: 'dummy'}, 74 | getGeocoderInterface(), 75 | MockCache 76 | ); 77 | }).throw('Missing privateKey'); 78 | }); 79 | 80 | it('should throw an error when there is only the private key', function() { 81 | should(function() { 82 | const geocoder = new Geocoder(// eslint-disable-line 83 | {privateKey: 'dummy'}, 84 | getGeocoderInterface(), 85 | MockCache 86 | ); 87 | }).throw('Missing clientId'); 88 | }); 89 | 90 | it('should throw an error when there is a client id & api key', function() { 91 | should(() => { 92 | const geocoder = new Geocoder( // eslint-disable-line 93 | { 94 | clientId: 'dummy', 95 | apiKey: 'dummy' 96 | }, 97 | getGeocoderInterface(), 98 | MockCache 99 | ); 100 | }).throw('Can only specify credentials or API key'); 101 | }); 102 | 103 | it('should throw an error when there is a private key & api key', function() { 104 | should(() => { 105 | const geocoder = new Geocoder( // eslint-disable-line 106 | { 107 | privateKey: 'dummy', 108 | apiKey: 'dummy' 109 | }, 110 | getGeocoderInterface(), 111 | MockCache 112 | ); 113 | }).throw('Can only specify credentials or API key'); 114 | }); 115 | 116 | it('should accept a maximum requests per second option', function() { 117 | const geocoder = new Geocoder( 118 | getGeocoderOptions({queriesPerSecond: 10}), 119 | getGeocoderInterface(), 120 | MockCache 121 | ); 122 | 123 | should.exist(geocoder); 124 | }); 125 | 126 | it('should accept a maximum number of retries option', function() { 127 | const geocoder = new Geocoder( 128 | getGeocoderOptions({maxRetries: 3}), 129 | getGeocoderInterface(), 130 | MockCache 131 | ); 132 | 133 | should.exist(geocoder); 134 | }); 135 | 136 | it('should not accept less than 1 query per second', function() { 137 | should(() => { 138 | const geocoder = new Geocoder( // eslint-disable-line no-unused-vars 139 | getGeocoderOptions({queriesPerSecond: 0.5}), 140 | getGeocoderInterface(), 141 | MockCache 142 | ); 143 | }).throw('Requests per second must be >= 1 and <= 50'); 144 | }); 145 | 146 | it('should not accept negative queries per second', function() { 147 | should(() => { 148 | const geocoder = new Geocoder( // eslint-disable-line no-unused-vars 149 | getGeocoderOptions({queriesPerSecond: -2}), 150 | getGeocoderInterface(), 151 | MockCache 152 | ); 153 | }).throw('Requests per second must be >= 1 and <= 50'); 154 | }); 155 | 156 | it('should not accept more than 50 queries per second', function() { 157 | should(() => { 158 | const geocoder = new Geocoder( // eslint-disable-line no-unused-vars 159 | getGeocoderOptions({queriesPerSecond: 51}), 160 | getGeocoderInterface(), 161 | MockCache 162 | ); 163 | }).throw('Requests per second must be >= 1 and <= 50'); 164 | }); 165 | 166 | it('should return a promise from the geocodeAddress function', () => { 167 | const geocodeFunction = getGeocodeFunction({error: 'error'}); 168 | 169 | const geoCoderInterface = getGeocoderInterface(geocodeFunction), 170 | geocoder = new Geocoder( 171 | getGeocoderOptions(), 172 | geoCoderInterface, 173 | MockCache 174 | ); 175 | 176 | geocoder.geocodeAddress('Hamburg').should.be.a.Promise; 177 | }); 178 | 179 | it('should call geocode function on geocodeAddress with correct parameter', 180 | function(done) { 181 | const mockAddress = 'anAddress', 182 | geocodeFunction = getGeocodeFunction({results: ['some result']}), 183 | geoCoderInterface = getGeocoderInterface(geocodeFunction), 184 | geocoder = new Geocoder( 185 | getGeocoderOptions(), 186 | geoCoderInterface, 187 | MockCache 188 | ); 189 | 190 | geocoder.geocodeAddress(mockAddress) 191 | .then(() => { 192 | sinon.assert.calledWith(geocodeFunction, {address: mockAddress}); 193 | }) 194 | .then(done, done); 195 | } 196 | ); 197 | 198 | it('should throw authentication error when using invalid client id', function(done) { // eslint-disable-line max-len 199 | const mockAddress = 'Hamburg', 200 | geocodeFunction = getGeocodeFunction({ 201 | error: { 202 | code: 403 203 | } 204 | }), 205 | geoCoderInterface = getGeocoderInterface(geocodeFunction), 206 | geocoder = new Geocoder( 207 | { 208 | clientId: 'dummy', 209 | privateKey: 'dummy' 210 | }, 211 | geoCoderInterface, 212 | MockCache); 213 | 214 | geocoder.geocodeAddress(mockAddress) 215 | .catch(error => { 216 | should(error).be.an.Error; 217 | should(error.message).equal('Authentication error'); 218 | }) 219 | .then(done, done); 220 | }); 221 | 222 | it('should throw authentication error when using invalid api key', function(done) { // eslint-disable-line max-len 223 | const mockAddress = 'Hamburg', 224 | geocodeFunction = getGeocodeFunction({ 225 | error: { 226 | code: 403 227 | } 228 | }), 229 | geoCoderInterface = getGeocoderInterface(geocodeFunction), 230 | geocoder = new Geocoder( 231 | { 232 | apiKey: 'dummy' 233 | }, 234 | geoCoderInterface, 235 | MockCache); 236 | 237 | geocoder.geocodeAddress(mockAddress) 238 | .catch(error => { 239 | should(error).be.an.Error; 240 | should(error.message).equal('Authentication error'); 241 | }) 242 | .then(done, done); 243 | }); 244 | 245 | it('should throw connection error when appropriate', function(done) { 246 | const mockAddress = 'Hamburg', 247 | geocodeFunction = getGeocodeFunction({ 248 | error: { 249 | code: 'ECONNREFUSED' 250 | } 251 | }), 252 | geoCoderInterface = getGeocoderInterface(geocodeFunction), 253 | geocoder = new Geocoder( 254 | getGeocoderOptions(), 255 | geoCoderInterface, 256 | MockCache); 257 | 258 | geocoder.geocodeAddress(mockAddress) 259 | .catch(error => { 260 | should(error).be.an.Error; 261 | should(error.message).equal('Could not connect to the Google Maps API'); 262 | }) 263 | .then(done, done); 264 | }); 265 | 266 | it('should geocode an address', function(done) { 267 | const mockAddress = 'Hamburg', 268 | geoCoderResult = ['mockResult'], 269 | expectedResult = geoCoderResult, 270 | geocodeFunction = getGeocodeFunction({results: geoCoderResult}), 271 | geoCoderInterface = getGeocoderInterface(geocodeFunction), 272 | geocoder = new Geocoder( 273 | getGeocoderOptions(), 274 | geoCoderInterface, 275 | MockCache 276 | ); 277 | 278 | geocoder.geocodeAddress(mockAddress) 279 | .then(result => { 280 | should(result).equal(expectedResult); 281 | }) 282 | .then(done, done); 283 | }); 284 | 285 | it('should cache a geocode', function(done) { 286 | const mockAddress = 'anAddress', 287 | geoCoderResult = ['mockResult'], 288 | geocodeFunction = getGeocodeFunction({results: geoCoderResult}), 289 | geoCoderInterface = getGeocoderInterface(geocodeFunction), 290 | addFunction = sinon.stub(); 291 | 292 | class NewMockCache extends MockCache { 293 | add(key, value) { 294 | addFunction(key, value); 295 | } 296 | } 297 | 298 | const geocoder = new Geocoder( 299 | getGeocoderOptions(), 300 | geoCoderInterface, 301 | NewMockCache 302 | ), 303 | geocode = geocoder.geocodeAddress(mockAddress); 304 | 305 | geocode 306 | .then(function() { 307 | sinon.assert.calledWith(addFunction, mockAddress, geoCoderResult); 308 | }) 309 | .then(done, done); 310 | }); 311 | 312 | it('should use the cached version if it exists', function(done) { 313 | const mockAddress = 'anAddress', 314 | cachedResult = 'a result from the cache', 315 | geoCoderResult = ['mockResult'], 316 | geocodeFunction = getGeocodeFunction({results: geoCoderResult}), 317 | geoCoderInterface = getGeocoderInterface(geocodeFunction); 318 | 319 | class NewMockCache extends MockCache { 320 | get() { 321 | return cachedResult; 322 | } 323 | } 324 | 325 | const geocoder = new Geocoder( 326 | getGeocoderOptions(), 327 | geoCoderInterface, 328 | NewMockCache 329 | ), 330 | geocode = geocoder.geocodeAddress(mockAddress); 331 | 332 | geocode 333 | .then(result => { 334 | should(result).equal(cachedResult); 335 | }) 336 | .then(done, done); 337 | }); 338 | 339 | it('should return an error when no result is found', function(done) { 340 | const mockAddress = 'My dummy location that does not exist!', 341 | geocodeFunction = getGeocodeFunction({results: []}), 342 | geoCoderInterface = getGeocoderInterface(geocodeFunction), 343 | geocoder = new Geocoder( 344 | getGeocoderOptions(), 345 | geoCoderInterface, 346 | MockCache 347 | ); 348 | 349 | geocoder.geocodeAddress(mockAddress).catch(error => { 350 | should(error).be.an.Error; 351 | should(error.message).equal('No results found'); 352 | done(); 353 | }); 354 | }); 355 | 356 | it('should return an error when over query limit', function(done) { 357 | const mockAddress = 'Hamburg', 358 | geocodeFunction = getGeocodeFunction({ 359 | results: ['Hamburg'], 360 | status: 'OVER_QUERY_LIMIT' 361 | }), 362 | geoCoderInterface = getGeocoderInterface(geocodeFunction), 363 | geocoder = new Geocoder( 364 | getGeocoderOptions(), 365 | geoCoderInterface, 366 | MockCache); 367 | 368 | geocoder.geocodeAddress(mockAddress) 369 | .catch(error => { 370 | should(error).be.an.Error; 371 | should(error.message).equal('Over query limit'); 372 | }) 373 | .then(done, done); 374 | }); 375 | 376 | it('should retry `maxRetries` times', function(done) { 377 | const mockAddress = 'Hamburg', 378 | geocodeFunction = getGeocodeFunction({ 379 | results: ['Hamburg'], 380 | status: 'OVER_QUERY_LIMIT' 381 | }), 382 | geoCoderInterface = getGeocoderInterface(geocodeFunction), 383 | geocoder = new Geocoder( 384 | getGeocoderOptions({ 385 | maxRetries: 2 386 | }), 387 | geoCoderInterface, 388 | MockCache); 389 | 390 | geocoder.geocodeAddress(mockAddress) 391 | .catch(() => { 392 | should(geocodeFunction.calledThrice).equal(true); 393 | }) 394 | .then(done, done); 395 | }); 396 | }); 397 | -------------------------------------------------------------------------------- /test/geocode-stream-test.js: -------------------------------------------------------------------------------- 1 | /* eslint-disable no-unused-expressions, max-nested-callbacks, 2 | no-underscore-dangle */ 3 | import sinon from 'sinon'; 4 | import should from 'should'; 5 | import stream from 'stream'; 6 | import intoStream from 'into-stream'; 7 | import streamAssert from 'stream-assert'; 8 | import Promise from 'lie'; 9 | import defaults from '../src/defaults'; 10 | 11 | import GeocodeStream from '../src/geocode-stream'; 12 | import {getGeocodeStream, getGeocoderInterface} from './lib/helpers'; 13 | 14 | describe('Geocode Stream', () => { 15 | it('be of type stream.Transform', () => { 16 | const geocodeStream = new GeocodeStream(); 17 | should(geocodeStream).be.an.instanceof(stream.Transform); 18 | }); 19 | 20 | it('have a _transform function', () => { 21 | const geocodeStream = new GeocodeStream(); 22 | should(geocodeStream._transform).be.a.Function; 23 | }); 24 | 25 | it('should take a geocoder', () => { 26 | const mockGeocoder = 'a geocoder', 27 | geocodeStream = new GeocodeStream(mockGeocoder); 28 | should(geocodeStream.geocoder).be.equal(mockGeocoder); 29 | }); 30 | 31 | it('should take stats', () => { 32 | const mockStats = 'some stats', 33 | geocodeStream = new GeocodeStream(null, 34 | defaults.defaultQueriesPerSecond, 35 | mockStats 36 | ); 37 | should(geocodeStream.stats).be.equal(mockStats); 38 | }); 39 | 40 | it('should take an accessor function', function() { 41 | const mockAccessor = sinon.stub(), 42 | geocodeStream = new GeocodeStream(null, 43 | defaults.defaultQueriesPerSecond, 44 | null, 45 | mockAccessor 46 | ); 47 | 48 | should(geocodeStream.accessor).equal(mockAccessor); 49 | }); 50 | 51 | describe('_transform should', () => { 52 | it('send an address to the geocoder', done => { 53 | const promise = Promise.resolve(), 54 | newGeocodeAddressFunction = sinon.stub().returns(promise), 55 | GeoCoderInterface = getGeocoderInterface( 56 | null, 57 | newGeocodeAddressFunction 58 | ), 59 | geocoder = GeoCoderInterface.init(), 60 | geocodeStream = new GeocodeStream(geocoder); 61 | 62 | geocodeStream._transform('test', null, () => {}); 63 | promise 64 | .then(() => { 65 | sinon.assert.calledWith(newGeocodeAddressFunction, 'test'); 66 | }) 67 | .then(done, done); 68 | }); 69 | 70 | it('call the \'done\' function when finishes succesfully', done => { 71 | const promise = Promise.resolve([{geometry: {location: null}}]), 72 | geocodeStream = getGeocodeStream(promise); 73 | 74 | const doneFunction = sinon.stub(); 75 | 76 | geocodeStream._transform('test2', null, doneFunction); 77 | 78 | promise 79 | .then(() => { 80 | sinon.assert.called(doneFunction); 81 | }) 82 | .then(done, done); 83 | }); 84 | 85 | it('call the \'done\' function when finishes with error', done => { 86 | const promise = Promise.reject(new Error('error')), 87 | geocodeStream = getGeocodeStream(promise), 88 | mockInput = 'some input', 89 | doneFunction = sinon.stub(); 90 | 91 | geocodeStream._transform(mockInput, null, doneFunction); 92 | 93 | promise 94 | .catch(() => { 95 | sinon.assert.called(doneFunction); 96 | }) 97 | .then(done, done); 98 | }); 99 | 100 | it('apply accessor before passing the address to the geocoder', done => { 101 | const mockAddress = 'an address', 102 | mockInput = {address: mockAddress}, 103 | accessorFunction = item => item.address, 104 | promise = Promise.resolve(), 105 | newGeocodeAddressFunction = sinon.stub().returns(promise), 106 | GeoCoderInterface = getGeocoderInterface( 107 | null, 108 | newGeocodeAddressFunction 109 | ), 110 | geocoder = GeoCoderInterface.init(), 111 | geocodeStream = new GeocodeStream(geocoder, 112 | defaults.defaultQueriesPerSecond, 113 | {}, 114 | accessorFunction 115 | ); 116 | 117 | geocodeStream._transform(mockInput, null, () => {}); 118 | promise 119 | .then(() => { 120 | sinon.assert.calledWith(newGeocodeAddressFunction, mockAddress); 121 | }) 122 | .then(done, done); 123 | }); 124 | }); 125 | 126 | describe('(on succesful geocode)', () => { 127 | it('add input result', done => { 128 | const mockInput = 'mockInput', 129 | mockStream = intoStream.obj([mockInput]), 130 | mockGeocoderResult = { 131 | geometry: {location: 2} 132 | }, 133 | promise = Promise.resolve(mockGeocoderResult), 134 | geocodeStream = getGeocodeStream(promise); 135 | 136 | mockStream 137 | .pipe(geocodeStream) 138 | .pipe(streamAssert.first(item => { 139 | should(item.input).equal(mockInput); 140 | })) 141 | .pipe(streamAssert.end(error => { 142 | done(error); 143 | })); 144 | }); 145 | 146 | it('add input address to result,', done => { 147 | const mockAccessor = input => input.address, 148 | mockAddress = 'mockAddress', 149 | mockInput = {address: mockAddress}, 150 | mockStream = intoStream.obj([mockInput]), 151 | mockGeocoderResult = { 152 | geometry: {location: 2} 153 | }, 154 | promise = Promise.resolve(mockGeocoderResult), 155 | geocodeStream = getGeocodeStream(promise); 156 | 157 | geocodeStream.accessor = mockAccessor; 158 | 159 | mockStream 160 | .pipe(geocodeStream) 161 | .pipe(streamAssert.first(item => { 162 | should(item.address).equal(mockAddress); 163 | })) 164 | .pipe(streamAssert.end(error => { 165 | done(error); 166 | })); 167 | }); 168 | 169 | it('add first geocoder result to result field', done => { 170 | const mockStream = intoStream.obj(['mockAddress']), 171 | mockGeocoderResult = [{ 172 | geometry: {location: 2} 173 | }], 174 | expectedResult = mockGeocoderResult[0], 175 | promise = Promise.resolve(mockGeocoderResult), 176 | geocodeStream = getGeocodeStream(promise); 177 | 178 | mockStream 179 | .pipe(geocodeStream) 180 | .pipe(streamAssert.first(item => { 181 | should(item.result).equal(expectedResult); 182 | })) 183 | .pipe(streamAssert.end(error => { 184 | done(error); 185 | })); 186 | }); 187 | 188 | it('add all geocoder results to results field', done => { 189 | const mockStream = intoStream.obj(['mockAddress']), 190 | mockGeocoderResult = [{ 191 | geometry: {location: 2} 192 | }], 193 | expectedResult = mockGeocoderResult, 194 | promise = Promise.resolve(mockGeocoderResult), 195 | geocodeStream = getGeocodeStream(promise); 196 | 197 | mockStream 198 | .pipe(geocodeStream) 199 | .pipe(streamAssert.first(item => { 200 | should(item.results).equal(expectedResult); 201 | })) 202 | .pipe(streamAssert.end(error => { 203 | done(error); 204 | })); 205 | }); 206 | 207 | it('add geocoder result location to result', done => { 208 | const mockStream = intoStream.obj(['mockAddress']), 209 | mockGeocoderResult = [{ 210 | geometry: {location: 2} 211 | }], 212 | expectedLocation = mockGeocoderResult[0].geometry.location, 213 | promise = Promise.resolve(mockGeocoderResult), 214 | geocodeStream = getGeocodeStream(promise); 215 | 216 | mockStream 217 | .pipe(geocodeStream) 218 | .pipe(streamAssert.first(item => { 219 | should(item.location).equal(expectedLocation); 220 | })) 221 | .pipe(streamAssert.end(error => { 222 | done(error); 223 | })); 224 | }); 225 | 226 | it('set error to null', done => { 227 | const mockStream = intoStream.obj(['mockAddress']), 228 | mockGeocoderResult = [{ 229 | geometry: {location: 2} 230 | }], 231 | promise = Promise.resolve(mockGeocoderResult), 232 | geocodeStream = getGeocodeStream(promise), 233 | expectedErrorMessage = null; 234 | 235 | mockStream 236 | .pipe(geocodeStream) 237 | .pipe(streamAssert.first(item => { 238 | should(item.error).equal(expectedErrorMessage); 239 | })) 240 | .pipe(streamAssert.end(error => { 241 | done(error); 242 | })); 243 | }); 244 | 245 | it('add stats fields to result when stats are delivered', done => { 246 | const mockStream = intoStream.obj(['mockAddress']), 247 | mockGeocoderResult = [{ 248 | geometry: {location: 2} 249 | }], 250 | promise = Promise.resolve(mockGeocoderResult), 251 | geocodeStream = getGeocodeStream(promise); 252 | 253 | geocodeStream.stats = { 254 | total: 0, 255 | current: 0, 256 | startTime: new Date() 257 | }; 258 | 259 | mockStream 260 | .pipe(geocodeStream) 261 | .pipe(streamAssert.first(item => { 262 | should(item).have 263 | .properties(['total', 'current', 'pending', 'percent']); 264 | })) 265 | .pipe(streamAssert.end(error => { 266 | done(error); 267 | })); 268 | }); 269 | 270 | it('add stats fields to result when stats are not given', done => { 271 | const mockStream = intoStream.obj(['mockAddress']), 272 | mockGeocoderResult = { 273 | geometry: {location: 2} 274 | }, 275 | promise = Promise.resolve(mockGeocoderResult), 276 | geocodeStream = getGeocodeStream(promise); 277 | 278 | mockStream 279 | .pipe(geocodeStream) 280 | .pipe(streamAssert.first(item => { 281 | should(item).have 282 | .properties(['current']); 283 | should(item).not.have 284 | .properties(['total', 'pending', 'percent']); 285 | })) 286 | .pipe(streamAssert.end(error => { 287 | done(error); 288 | })); 289 | }); 290 | }); 291 | 292 | describe('(on unsuccesful geocode)', () => { 293 | it('add input address to result', done => { 294 | const mockAddress = 'mockAddress', 295 | mockStream = intoStream.obj([mockAddress]), 296 | promise = Promise.reject(new Error()), 297 | geocodeStream = getGeocodeStream(promise); 298 | 299 | mockStream 300 | .pipe(geocodeStream) 301 | .pipe(streamAssert.first(item => { 302 | should(item.address).equal(mockAddress); 303 | })) 304 | .pipe(streamAssert.end(error => { 305 | done(error); 306 | })); 307 | }); 308 | 309 | it('add empty object to result', done => { 310 | const mockStream = intoStream.obj(['mockAddress']), 311 | promise = Promise.reject(new Error()), 312 | geocodeStream = getGeocodeStream(promise), 313 | expectedResult = {}; 314 | 315 | mockStream 316 | .pipe(geocodeStream) 317 | .pipe(streamAssert.first(item => { 318 | should(item.result).deepEqual(expectedResult); 319 | })) 320 | .pipe(streamAssert.end(error => { 321 | done(error); 322 | })); 323 | }); 324 | 325 | it('add empty location to result', done => { 326 | const mockStream = intoStream.obj(['mockAddress']), 327 | promise = Promise.reject(new Error()), 328 | geocodeStream = getGeocodeStream(promise), 329 | expectedLocation = {}; 330 | 331 | mockStream 332 | .pipe(geocodeStream) 333 | .pipe(streamAssert.first(item => { 334 | should(item.location).deepEqual(expectedLocation); 335 | })) 336 | .pipe(streamAssert.end(error => { 337 | done(error); 338 | })); 339 | }); 340 | 341 | it('add error message on geocoder error', done => { 342 | const mockStream = intoStream.obj(['mockAddress']), 343 | mockErrorMessage = 'an error message', 344 | mockError = new Error(mockErrorMessage), 345 | promise = Promise.reject(mockError), 346 | geocodeStream = getGeocodeStream(promise); 347 | 348 | mockStream 349 | .pipe(geocodeStream) 350 | .pipe(streamAssert.first(item => { 351 | should(item.error).equal(mockErrorMessage); 352 | })) 353 | .pipe(streamAssert.end(error => { 354 | done(error); 355 | })); 356 | }); 357 | 358 | it('add stats fields to result if stats are given', done => { 359 | const mockStream = intoStream.obj(['mockAddress']), 360 | mockGeocoderResult = { 361 | geometry: {location: 2} 362 | }, 363 | promise = Promise.resolve(mockGeocoderResult), 364 | geocodeStream = getGeocodeStream(promise); 365 | 366 | geocodeStream.stats = { 367 | total: 0, 368 | current: 0, 369 | startTime: new Date() 370 | }; 371 | 372 | mockStream 373 | .pipe(geocodeStream) 374 | .pipe(streamAssert.first(item => { 375 | should(item).have 376 | .properties(['total', 'current', 'pending', 'percent']); 377 | })) 378 | .pipe(streamAssert.end(error => { 379 | done(error); 380 | })); 381 | }); 382 | 383 | it('add some stats fields to result if stats are not given', done => { 384 | const mockStream = intoStream.obj(['mockAddress']), 385 | mockGeocoderResult = { 386 | geometry: {location: 2} 387 | }, 388 | promise = Promise.resolve(mockGeocoderResult), 389 | geocodeStream = getGeocodeStream(promise); 390 | 391 | mockStream 392 | .pipe(geocodeStream) 393 | .pipe(streamAssert.first(item => { 394 | should(item).have 395 | .properties(['current']); 396 | should(item).not.have 397 | .properties(['total', 'pending', 'percent']); 398 | })) 399 | .pipe(streamAssert.end(error => { 400 | done(error); 401 | })); 402 | }); 403 | }); 404 | }); 405 | /* eslint-enable */ 406 | --------------------------------------------------------------------------------