diff --git a/README.md b/README.md index 725443f2..652f289c 100644 --- a/README.md +++ b/README.md @@ -9,16 +9,6 @@ See our [API Documentation](https://github.com/pelias/api/blob/master/public/api ## Install Dependencies -The API uses [elasticsearch scripts](https://github.com/pelias/scripts) for additional scoring/sorting logic. You -**must** install them, as documented [here](https://github.com/pelias/scripts#pelias-scripts). Failure to do so will -result in the following error: - -``` -ElasticsearchIllegalArgumentException[Unable to find on disk script admin_boost] -``` - -Once you are done with installing the scripts, Run the following - ```bash npm install ``` diff --git a/controller/place.js b/controller/place.js index 2a4462cc..c4e78978 100644 --- a/controller/place.js +++ b/controller/place.js @@ -13,14 +13,35 @@ function setup( backend ){ return next(); } - var query = req.clean.ids.map( function(id) { + /* req.clean.ids contains an array of objects with id and types properties. + * types is an array of one or more types, since it can't always be known which single + * type a gid might belong to (osmnode and osmway both have source osm and layer venue). + * + * However, the mget Elasticsearch query only accepts a single type at a + * time. + * + * So, first create a new array that, has an entry + * with each type and id combination. This requires creating a new array with more entries + * than req.clean.ids in the case where entries have multiple types. + */ + + var recordsToReturn = req.clean.ids.reduce(function (acc, ids_element) { + ids_element.types.forEach(function(type) { + acc.push({ + id: ids_element.id, + type: type + }); + }); + return acc; + }, []); + + /* + * Next, map the list of records to an Elasticsearch mget query + */ + var query = recordsToReturn.map( function(id) { return { _index: 'pelias', - /* - * some gids aren't resolvable to a single type (ex: osmnode and osmway - * both have source osm and layer venue), so expect an array of - * possible values. */ - _type: id.types, + _type: id.type, _id: id.id }; }); diff --git a/query/search.js b/query/search.js index b93178a9..a30aa2fe 100644 --- a/query/search.js +++ b/query/search.js @@ -1,7 +1,8 @@ var peliasQuery = require('pelias-query'), defaults = require('./defaults'), textParser = require('./text_parser'), - check = require('check-types'); + check = require('check-types'), + geolib = require('geolib'); //------------------------------ // general-purpose search query @@ -65,13 +66,15 @@ function generateQuery( clean ){ } // focus viewport - // @todo: change these to the correct request variable names - // @todo: calculate the centroid from the viewport box - if( clean.focus && clean.focus.viewport ){ - var vp = clean.focus.viewport; + if( check.number(clean['focus.viewport.min_lat']) && + check.number(clean['focus.viewport.max_lat']) && + check.number(clean['focus.viewport.min_lon']) && + check.number(clean['focus.viewport.max_lon']) ) { + // calculate the centroid from the viewport box vs.set({ - 'focus:point:lat': vp.min_lat + ( vp.max_lat - vp.min_lat ) / 2, - 'focus:point:lon': vp.min_lon + ( vp.max_lon - vp.min_lon ) / 2 + 'focus:point:lat': clean['focus.viewport.min_lat'] + ( clean['focus.viewport.max_lat'] - clean['focus.viewport.min_lat'] ) / 2, + 'focus:point:lon': clean['focus.viewport.min_lon'] + ( clean['focus.viewport.max_lon'] - clean['focus.viewport.min_lon'] ) / 2, + 'focus:scale': calculateDiagonalDistance(clean) + 'km' }); } @@ -119,4 +122,16 @@ function generateQuery( clean ){ return query.render( vs ); } +// return diagonal distance in km, with min=1 +function calculateDiagonalDistance(clean) { + var diagonalDistance = geolib.getDistance( + { latitude: clean['focus.viewport.min_lat'], longitude: clean['focus.viewport.min_lon'] }, + { latitude: clean['focus.viewport.max_lat'], longitude: clean['focus.viewport.max_lon'] }, + 1000 + ) / 1000; + + return Math.max(diagonalDistance, 1); + +} + module.exports = generateQuery; diff --git a/sanitiser/_geo_common.js b/sanitiser/_geo_common.js index 0ca1184e..3287bc31 100644 --- a/sanitiser/_geo_common.js +++ b/sanitiser/_geo_common.js @@ -36,7 +36,7 @@ function sanitize_rect( key_prefix, clean, raw, bbox_is_required ) { // check each property individually. now that it is known a bbox is present, // all properties must exist, so pass the true flag for coord_is_required properties.forEach(function(prop) { - sanitize_coord(prop, clean, raw[prop], true); + sanitize_coord(prop, clean, raw, true); }); } @@ -52,7 +52,7 @@ function sanitize_circle( key_prefix, clean, raw, circle_is_required ) { // sanitize both a point and a radius if radius is present // otherwise just sanittize the point if( check.assigned( raw[ key_prefix + '.radius' ] ) ){ - sanitize_coord( key_prefix + '.radius', clean, raw[ key_prefix + '.radius' ], true ); + sanitize_coord( key_prefix + '.radius', clean, raw, true ); sanitize_point( key_prefix, clean, raw, true); } else { sanitize_point( key_prefix, clean, raw, circle_is_required); @@ -89,20 +89,20 @@ function sanitize_point( key_prefix, clean, raw, point_is_required ) { // check each property individually. now that it is known a bbox is present, // all properties must exist, so pass the true flag for coord_is_required properties.forEach(function(prop) { - sanitize_coord(prop, clean, raw[prop], true); + sanitize_coord(prop, clean, raw, true); }); } /** * Validate lat,lon values * - * @param {string} key - * @param {object} clean - * @param {string} rawValue + * @param {string} key - which key to validate + * @param {object} clean - cleaned parameters object + * @param {object} raw - the raw request object * @param {bool} latlon_is_required */ -function sanitize_coord( key, clean, rawValue, latlon_is_required ) { - var parsedValue = parseFloat( rawValue ); +function sanitize_coord( key, clean, raw, latlon_is_required ) { + var parsedValue = parseFloat( raw[key] ); if ( _.isFinite( parsedValue ) ) { clean[key] = parsedValue; } diff --git a/sanitiser/_geo_search.js b/sanitiser/_geo_search.js index badeec52..8aef9b7f 100644 --- a/sanitiser/_geo_search.js +++ b/sanitiser/_geo_search.js @@ -1,7 +1,9 @@ +var check = require('check-types'); var geo_common = require ('./_geo_common'); var LAT_LON_IS_REQUIRED = false; var RECT_IS_REQUIRED = false; var CIRCLE_IS_REQUIRED = false; +var VIEWPORT_IS_REQUIRED = false; // validate inputs, convert types and apply defaults module.exports = function sanitize( raw, clean ){ @@ -9,10 +11,22 @@ module.exports = function sanitize( raw, clean ){ // error & warning messages var messages = { errors: [], warnings: [] }; + // disallow specifying both focus.point and focus.viewport + if ( ( raw['focus.viewport.min_lat'] || + raw['focus.viewport.max_lat'] || + raw['focus.viewport.min_lon'] || + raw['focus.viewport.max_lon'] ) && + ( raw['focus.point.lat'] || + raw['focus.point.lon'] ) ) { + messages.errors.push( 'focus.point and focus.viewport can\'t both be set' ); + return messages; + } + try { geo_common.sanitize_point( 'focus.point', clean, raw, LAT_LON_IS_REQUIRED ); geo_common.sanitize_rect( 'boundary.rect', clean, raw, RECT_IS_REQUIRED ); geo_common.sanitize_circle( 'boundary.circle', clean, raw, CIRCLE_IS_REQUIRED ); + geo_common.sanitize_rect( 'focus.viewport', clean, raw, VIEWPORT_IS_REQUIRED ); } catch (err) { messages.errors.push( err.message ); diff --git a/test/unit/controller/place.js b/test/unit/controller/place.js index 1d38f5d5..e8a8c333 100644 --- a/test/unit/controller/place.js +++ b/test/unit/controller/place.js @@ -41,7 +41,7 @@ module.exports.tests.functional_success = function(test, common) { test('functional success', function(t) { var backend = mockBackend( 'client/mget/ok/1', function( cmd ){ - t.deepEqual(cmd, { body: { docs: [ { _id: 123, _index: 'pelias', _type: [ 'a' ] } ] } }, 'correct backend command'); + t.deepEqual(cmd, { body: { docs: [ { _id: 123, _index: 'pelias', _type: 'a' } ] } }, 'correct backend command'); }); var controller = setup( backend ); var res = { @@ -70,7 +70,7 @@ module.exports.tests.functional_success = function(test, common) { module.exports.tests.functional_failure = function(test, common) { test('functional failure', function(t) { var backend = mockBackend( 'client/mget/fail/1', function( cmd ){ - t.deepEqual(cmd, { body: { docs: [ { _id: 123, _index: 'pelias', _type: [ 'b' ] } ] } }, 'correct backend command'); + t.deepEqual(cmd, { body: { docs: [ { _id: 123, _index: 'pelias', _type: 'b' } ] } }, 'correct backend command'); }); var controller = setup( backend ); var req = { clean: { ids: [ {'id' : 123, types: [ 'b' ] } ] }, errors: [], warnings: [] }; diff --git a/test/unit/fixture/search_linguistic_viewport.js b/test/unit/fixture/search_linguistic_viewport.js new file mode 100644 index 00000000..46cdc3e9 --- /dev/null +++ b/test/unit/fixture/search_linguistic_viewport.js @@ -0,0 +1,136 @@ +module.exports = { + 'query': { + 'filtered': { + 'query': { + 'bool': { + 'must': [ + { + 'match': { + 'name.default': { + 'analyzer': 'peliasOneEdgeGram', + 'boost': 1, + 'query': 'test' + } + } + } + ], + 'should': [ + { + 'match': { + 'phrase.default': { + 'analyzer': 'peliasPhrase', + 'type': 'phrase', + 'boost': 1, + 'slop': 2, + 'query': 'test' + } + } + }, + { + 'function_score': { + 'query': { + 'match': { + 'phrase.default': { + 'analyzer': 'peliasPhrase', + 'type': 'phrase', + 'boost': 1, + 'slop': 2, + 'query': 'test' + } + } + }, + 'functions': [ + { + 'weight': 2, + 'linear': { + 'center_point': { + 'origin': { + 'lat': 29.49136, + 'lon': -82.50622 + }, + 'offset': '1km', + 'scale': '994km', + 'decay': 0.5 + } + } + } + ], + 'score_mode': 'avg', + 'boost_mode': 'replace' + } + }, + { + 'function_score': { + 'query': { + 'match': { + 'phrase.default': { + 'analyzer': 'peliasPhrase', + 'type': 'phrase', + 'boost': 1, + 'slop': 2, + 'query': 'test' + } + } + }, + 'max_boost': 20, + 'functions': [ + { + 'field_value_factor': { + 'modifier': 'log1p', + 'field': 'popularity' + }, + 'weight': 1 + } + ], + 'score_mode': 'first', + 'boost_mode': 'replace', + 'filter': { + 'exists': { + 'field': 'popularity' + } + } + } + }, + { + 'function_score': { + 'query': { + 'match': { + 'phrase.default': { + 'analyzer': 'peliasPhrase', + 'type': 'phrase', + 'boost': 1, + 'slop': 2, + 'query': 'test' + } + } + }, + 'max_boost': 20, + 'functions': [ + { + 'field_value_factor': { + 'modifier': 'log1p', + 'field': 'population' + }, + 'weight': 2 + } + ], + 'score_mode': 'first', + 'boost_mode': 'replace', + 'filter': { + 'exists': { + 'field': 'population' + } + } + } + } + ] + } + } + } + }, + 'size': 10, + 'track_scores': true, + 'sort': [ + '_score' + ] +}; diff --git a/test/unit/fixture/search_linguistic_viewport_min_diagonal.js b/test/unit/fixture/search_linguistic_viewport_min_diagonal.js new file mode 100644 index 00000000..c5306919 --- /dev/null +++ b/test/unit/fixture/search_linguistic_viewport_min_diagonal.js @@ -0,0 +1,136 @@ +module.exports = { + 'query': { + 'filtered': { + 'query': { + 'bool': { + 'must': [ + { + 'match': { + 'name.default': { + 'analyzer': 'peliasOneEdgeGram', + 'boost': 1, + 'query': 'test' + } + } + } + ], + 'should': [ + { + 'match': { + 'phrase.default': { + 'analyzer': 'peliasPhrase', + 'type': 'phrase', + 'boost': 1, + 'slop': 2, + 'query': 'test' + } + } + }, + { + 'function_score': { + 'query': { + 'match': { + 'phrase.default': { + 'analyzer': 'peliasPhrase', + 'type': 'phrase', + 'boost': 1, + 'slop': 2, + 'query': 'test' + } + } + }, + 'functions': [ + { + 'weight': 2, + 'linear': { + 'center_point': { + 'origin': { + 'lat': 28.49136, + 'lon': -87.50623 + }, + 'offset': '1km', + 'scale': '1km', + 'decay': 0.5 + } + } + } + ], + 'score_mode': 'avg', + 'boost_mode': 'replace' + } + }, + { + 'function_score': { + 'query': { + 'match': { + 'phrase.default': { + 'analyzer': 'peliasPhrase', + 'type': 'phrase', + 'boost': 1, + 'slop': 2, + 'query': 'test' + } + } + }, + 'max_boost': 20, + 'functions': [ + { + 'field_value_factor': { + 'modifier': 'log1p', + 'field': 'popularity' + }, + 'weight': 1 + } + ], + 'score_mode': 'first', + 'boost_mode': 'replace', + 'filter': { + 'exists': { + 'field': 'popularity' + } + } + } + }, + { + 'function_score': { + 'query': { + 'match': { + 'phrase.default': { + 'analyzer': 'peliasPhrase', + 'type': 'phrase', + 'boost': 1, + 'slop': 2, + 'query': 'test' + } + } + }, + 'max_boost': 20, + 'functions': [ + { + 'field_value_factor': { + 'modifier': 'log1p', + 'field': 'population' + }, + 'weight': 2 + } + ], + 'score_mode': 'first', + 'boost_mode': 'replace', + 'filter': { + 'exists': { + 'field': 'population' + } + } + } + } + ] + } + } + } + }, + 'size': 10, + 'track_scores': true, + 'sort': [ + '_score' + ] +}; diff --git a/test/unit/query/search.js b/test/unit/query/search.js index e3db9287..3a11e054 100644 --- a/test/unit/query/search.js +++ b/test/unit/query/search.js @@ -73,6 +73,40 @@ module.exports.tests.query = function(test, common) { t.end(); }); + test('search search + viewport', function(t) { + var query = generate({ + text: 'test', size: 10, + 'focus.viewport.min_lat': 28.49136, + 'focus.viewport.max_lat': 30.49136, + 'focus.viewport.min_lon': -87.50622, + 'focus.viewport.max_lon': -77.50622, + layers: ['test'] + }); + + var compiled = JSON.parse( JSON.stringify( query ) ); + var expected = require('../fixture/search_linguistic_viewport'); + + t.deepEqual(compiled, expected, 'valid search query'); + t.end(); + }); + + test('search with viewport diagonal < 1km should set scale to 1km', function(t) { + var query = generate({ + text: 'test', size: 10, + 'focus.viewport.min_lat': 28.49135, + 'focus.viewport.max_lat': 28.49137, + 'focus.viewport.min_lon': -87.50622, + 'focus.viewport.max_lon': -87.50624, + layers: ['test'] + }); + + var compiled = JSON.parse( JSON.stringify( query ) ); + var expected = require('../fixture/search_linguistic_viewport_min_diagonal'); + + t.deepEqual(compiled, expected, 'valid search query'); + t.end(); + }); + test('search search + focus on null island', function(t) { var query = generate({ text: 'test', size: 10, diff --git a/test/unit/sanitiser/_geo_common.js b/test/unit/sanitiser/_geo_common.js index 47ae29fe..f8a27a8b 100644 --- a/test/unit/sanitiser/_geo_common.js +++ b/test/unit/sanitiser/_geo_common.js @@ -20,7 +20,7 @@ module.exports.tests.coord = function(test, common) { }; var mandatory = false; - sanitize.sanitize_coord( 'foo', clean, params.foo, mandatory ); + sanitize.sanitize_coord( 'foo', clean, params, mandatory ); t.equal(clean.foo, params.foo); t.end(); }); @@ -32,7 +32,7 @@ module.exports.tests.coord = function(test, common) { }; var mandatory = false; - sanitize.sanitize_coord( 'foo', clean, params.foo, mandatory ); + sanitize.sanitize_coord( 'foo', clean, params, mandatory ); t.equal(clean.foo, undefined, 'not set'); t.end(); }); @@ -43,7 +43,7 @@ module.exports.tests.coord = function(test, common) { var mandatory = false; t.doesNotThrow( function(){ - sanitize.sanitize_coord( 'foo', clean, params.foo, mandatory ); + sanitize.sanitize_coord( 'foo', clean, params, mandatory ); }); t.end(); }); @@ -54,7 +54,7 @@ module.exports.tests.coord = function(test, common) { var mandatory = true; t.throws( function(){ - sanitize.sanitize_coord( 'foo', clean, params.foo, mandatory ); + sanitize.sanitize_coord( 'foo', clean, params, mandatory ); }); t.end(); }); @@ -172,6 +172,23 @@ module.exports.tests.rect = function(test, common) { t.end(); }); + test('valid rect quad, null island', function (t) { + var clean = {}; + var params = { + 'boundary.rect.min_lat': 0, + 'boundary.rect.max_lat': 0, + 'boundary.rect.min_lon': 0, + 'boundary.rect.max_lon': 0 + }; + var mandatory = false; + + sanitize.sanitize_rect( 'boundary.rect', clean, params, mandatory ); + t.equal(clean['boundary.rect.min_lat'], params['boundary.rect.min_lat'], 'min_lat approved'); + t.equal(clean['boundary.rect.max_lat'], params['boundary.rect.max_lat'], 'min_lat approved'); + t.equal(clean['boundary.rect.min_lon'], params['boundary.rect.min_lon'], 'min_lat approved'); + t.equal(clean['boundary.rect.max_lon'], params['boundary.rect.max_lon'], 'min_lat approved'); + t.end(); + }); test('invalid rect - partially specified', function (t) { var clean = {}; var params = { diff --git a/test/unit/sanitiser/search.js b/test/unit/sanitiser/search.js index 59bc87f3..4f4e2880 100644 --- a/test/unit/sanitiser/search.js +++ b/test/unit/sanitiser/search.js @@ -5,7 +5,6 @@ var extend = require('extend'), middleware = search.middleware, defaultError = 'invalid param \'text\': text length, must be >0'; // these are the default values you would expect when no input params are specified. -// @todo: why is this different from $defaultClean? var emptyClean = { private: false, size: 10, types: {} }; module.exports.tests = {}; @@ -186,74 +185,94 @@ module.exports.tests.sanitize_optional_geo = function(test, common) { }; module.exports.tests.sanitize_bounding_rect = function(test, common) { + test('valid bounding rect', function(t) { + var req = { + query: { + text: 'test', + 'boundary.rect.min_lat': -40.659, + 'boundary.rect.max_lat': -41.614, + 'boundary.rect.min_lon': 174.612, + 'boundary.rect.max_lon': 176.333 + } + }; - // convernience function to avoid refactoring the succict geojson bbox - // fixtures in to the more verbose bounding.rect format. - var mapGeoJsonBboxFormatToBoundingRectFormat = function( bbox ){ - var split = bbox.split(','); - return { - 'boundary.rect.min_lon': split[0], - 'boundary.rect.max_lat': split[1], - 'boundary.rect.max_lon': split[2], - 'boundary.rect.min_lat': split[3] + sanitize(req, function(){ + t.equal(req.errors[0], undefined, 'no error'); + t.equal(req.clean['boundary.rect.min_lon'], parseFloat(req.query['boundary.rect.min_lon'])); + t.equal(req.clean['boundary.rect.max_lat'], parseFloat(req.query['boundary.rect.max_lat'])); + t.equal(req.clean['boundary.rect.max_lon'], parseFloat(req.query['boundary.rect.max_lon'])); + t.equal(req.clean['boundary.rect.min_lat'], parseFloat(req.query['boundary.rect.min_lat'])); + t.end(); + }); + }); +}; + +module.exports.tests.sanitize_viewport = function(test, common) { + test('valid viewport', function(t) { + var req = { + query: { + text: 'test', + 'focus.viewport.min_lat': '37', + 'focus.viewport.max_lat': '38', + 'focus.viewport.min_lon': '-123', + 'focus.viewport.max_lon': '-122' + } }; - }; + sanitize(req, function() { + t.equal(req.errors[0], undefined, 'no error'); + t.equal(req.clean['focus.viewport.min_lat'], parseFloat(req.query['focus.viewport.min_lat']), 'correct min_lat in clean'); + t.equal(req.clean['focus.viewport.max_lat'], parseFloat(req.query['focus.viewport.max_lat']), 'correct max_lat in clean'); + t.equal(req.clean['focus.viewport.min_lon'], parseFloat(req.query['focus.viewport.min_lon']), 'correct min_lon in clean'); + t.equal(req.clean['focus.viewport.max_lon'], parseFloat(req.query['focus.viewport.max_lon']), 'correct max_lon in clean'); + t.end(); + }); + }); - var bboxes = { - invalid: [ - '91;-181,-91,181', // invalid - semicolon between coordinates - 'these,are,not,numbers', - '0,0,0,notANumber', - ',,,', - '91, -181, -91', // invalid - missing a coordinate - '123,12', // invalid - missing coordinates - '' // invalid - empty param - ].map(mapGeoJsonBboxFormatToBoundingRectFormat), - - valid: [ - '-179,90,34,-80', // valid top_right lon/lat, bottom_left lon/lat - '0,0,0,0', - '10,20,30,40', - '-40,-20,10,30', - '-40,-20,10,30', - '-1200,20,1000,20', - '-1400,90,1400,-90', - // wrapped latitude coordinates - '-181,90,34,-180', - '-170,91,-181,45', - '-181,91,181,-91', - '91, -181,-91,11', - '91, -11,-91,181' - ].map(mapGeoJsonBboxFormatToBoundingRectFormat) - }; + test('error returned if focus.point and focus.viewpoint specified', function(t) { + var req = { + query: { + text: 'test', + 'focus.point.lat': '10', + 'focus.point.lon': '15', + 'focus.viewport.min_lat': '37', + 'focus.viewport.max_lat': '38', + 'focus.viewport.min_lon': '-123', + 'focus.viewport.max_lon': '-122' + } + }; - test('invalid bounding rect', function(t) { - bboxes.invalid.forEach( function( bbox ){ - var req = { query: { text: 'test' } }; - extend( req.query, bbox ); - sanitize(req, function(){ - t.equal(req.clean['boundary.rect.min_lon'], undefined); - t.equal(req.clean['boundary.rect.max_lat'], undefined); - t.equal(req.clean['boundary.rect.max_lon'], undefined); - t.equal(req.clean['boundary.rect.min_lat'], undefined); - t.equal(req.errors.length, 1, 'bounding error'); - }); + sanitize(req, function() { + t.equal(req.errors[0], 'focus.point and focus.viewport can\'t both be set', 'no error'); + t.notOk(req.clean.hasOwnProperty('focus.viewport.min_lat'), 'clean should be empty'); + t.notOk(req.clean.hasOwnProperty('focus.viewport.max_lat'), 'clean should be empty'); + t.notOk(req.clean.hasOwnProperty('focus.viewport.min_lon'), 'clean should be empty'); + t.notOk(req.clean.hasOwnProperty('focus.viewport.max_lon'), 'clean should be empty'); + t.notOk(req.clean.hasOwnProperty('focus.point.lat'), 'clean should be empty'); + t.notOk(req.clean.hasOwnProperty('focus.point.lon'), 'clean should be empty'); + t.end(); }); - t.end(); }); - test('valid bounding rect', function(t) { - bboxes.valid.forEach( function( bbox ){ - var req = { query: { text: 'test' } }; - extend( req.query, bbox ); - sanitize(req, function(){ - t.equal(req.errors[0], undefined, 'no error'); - t.equal(req.clean['boundary.rect.min_lon'], parseFloat(bbox['boundary.rect.min_lon'])); - t.equal(req.clean['boundary.rect.max_lat'], parseFloat(bbox['boundary.rect.max_lat'])); - t.equal(req.clean['boundary.rect.max_lon'], parseFloat(bbox['boundary.rect.max_lon'])); - t.equal(req.clean['boundary.rect.min_lat'], parseFloat(bbox['boundary.rect.min_lat'])); - }); + + test('error returned if focus.point and focus.viewpoint partially specified', function(t) { + var req = { + query: { + text: 'test', + 'focus.point.lat': '10', + 'focus.viewport.min_lat': '37', + 'focus.viewport.max_lon': '-122' + } + }; + + sanitize(req, function() { + t.equal(req.errors[0], 'focus.point and focus.viewport can\'t both be set', 'no error'); + t.notOk(req.clean.hasOwnProperty('focus.viewport.min_lat'), 'clean should be empty'); + t.notOk(req.clean.hasOwnProperty('focus.viewport.max_lat'), 'clean should be empty'); + t.notOk(req.clean.hasOwnProperty('focus.viewport.min_lon'), 'clean should be empty'); + t.notOk(req.clean.hasOwnProperty('focus.viewport.max_lon'), 'clean should be empty'); + t.notOk(req.clean.hasOwnProperty('focus.point.lat'), 'clean should be empty'); + t.notOk(req.clean.hasOwnProperty('focus.point.lon'), 'clean should be empty'); + t.end(); }); - t.end(); }); };