/* eslint-disable @typescript-eslint/no-explicit-any,unicorn/no-null */ /* * Copyright (C) 2020 StApps * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ import { SCConfigFile, SCSearchBooleanFilter, SCSearchDateRangeFilter, SCSearchFilter, SCSearchNumericRangeFilter, SCSearchQuery, SCSearchSort, SCThingType, } from '@openstapps/core'; import {expect} from 'chai'; import { ESDateRangeFilter, ESRangeFilter, ESNumericRangeFilter, ElasticsearchConfig, ESBooleanFilter, ESGenericSort, ESGeoDistanceFilter, ESGeoDistanceSort, ESTermFilter, ScriptSort, } from '../../../src/storage/elasticsearch/types/elasticsearch'; import {configFile} from '../../../src/common'; import { buildBooleanFilter, buildFilter, buildQuery, buildSort, } from '../../../src/storage/elasticsearch/query'; describe('Query', function () { describe('buildBooleanFilter', function () { const booleanFilter: SCSearchBooleanFilter = { arguments: { operation: 'and', filters: [ { type: 'value', arguments: { field: 'type', value: SCThingType.Catalog, }, }, { type: 'value', arguments: { field: 'type', value: SCThingType.Building, }, }, ], }, type: 'boolean', }; const booleanFilters: {[key: string]: SCSearchBooleanFilter} = { and: booleanFilter, or: {...booleanFilter, arguments: {...booleanFilter.arguments, operation: 'or'}}, not: {...booleanFilter, arguments: {...booleanFilter.arguments, operation: 'not'}}, }; const expectedEsFilters: Array = [ { term: { 'type.raw': 'catalog', }, }, { term: { 'type.raw': 'building', }, }, ]; it('should create appropriate elasticsearch "and" filter argument', function () { const {must} = buildBooleanFilter(booleanFilters.and); expect(must).to.be.eql(expectedEsFilters); }); it('should create appropriate elasticsearch "or" filter argument', function () { const {should, minimum_should_match} = buildBooleanFilter(booleanFilters.or); expect(should).to.be.eql(expectedEsFilters); expect(minimum_should_match).to.be.equal(1); }); it('should create appropriate elasticsearch "not" filter argument', function () { const {must_not} = buildBooleanFilter(booleanFilters.not); expect(must_not).to.be.eql(expectedEsFilters); }); }); describe('buildQuery', function () { const parameters: SCSearchQuery = { query: 'mathematics', from: 30, size: 5, sort: [ { type: 'ducet', order: 'desc', arguments: { field: 'name', }, }, { type: 'ducet', order: 'desc', arguments: { field: 'categories', }, }, ], filter: { type: 'value', arguments: { field: 'type', value: SCThingType.AcademicEvent, }, }, }; let esConfig: ElasticsearchConfig = { name: 'elasticsearch', version: '123', query: { minMatch: '75%', queryType: 'dis_max', matchBoosting: 1.3, fuzziness: 'AUTO', cutoffFrequency: 0, tieBreaker: 0, }, }; const query = { minMatch: '75%', queryType: 'dis_max', matchBoosting: 1.3, fuzziness: 'AUTO', cutoffFrequency: 0, tieBreaker: 0, }; const config: SCConfigFile = { ...configFile, }; beforeEach(function () { esConfig = { name: 'elasticsearch', version: '123', }; }); // TODO: check parts of received elasticsearch query for each test case it('should build query that includes sorting when query is undefined', function () { expect(buildQuery(parameters, config, esConfig)).to.be.an('object'); }); it('should build query that includes sorting when query type is query_string', function () { esConfig.query = {...query, queryType: 'query_string'}; expect(buildQuery(parameters, config, esConfig)).to.be.an('object'); }); it('should build query that includes sorting when query type is dis_max', function () { esConfig.query = {...query, queryType: 'dis_max'}; expect(buildQuery(parameters, config, esConfig)).to.be.an('object'); }); it('should build query that includes sorting when query type is dis_max', function () { esConfig.query = {...query, queryType: 'dis_max'}; expect(buildQuery(parameters, config, esConfig)).to.be.an('object'); }); it('should reject (throw an error) if provided query type is not supported', function () { // @ts-expect-error not assignable esConfig.query = {...query, queryType: 'invalid_query_type'}; expect(() => buildQuery(parameters, config, esConfig)).to.throw('query type'); }); }); describe('buildFilter', function () { const searchFilters: {[key: string]: SCSearchFilter} = { value: { type: 'value', arguments: { field: 'type', value: SCThingType.Dish, }, }, distance: { type: 'distance', arguments: { distance: 1000, field: 'geo', position: [50.123, 8.123], }, }, geoPoint: { type: 'geo', arguments: { field: 'geo', shape: { type: 'envelope', coordinates: [ [50.123, 8.123], [50.123, 8.123], ], }, }, }, geoShape: { type: 'geo', arguments: { field: 'geo', spatialRelation: 'contains', shape: { type: 'envelope', coordinates: [ [50.123, 8.123], [50.123, 8.123], ], }, }, }, boolean: { type: 'boolean', arguments: { operation: 'and', filters: [ { type: 'value', arguments: { field: 'type', value: SCThingType.Dish, }, }, { type: 'availability', arguments: { field: 'offers.availabilityRange', }, }, ], }, }, }; it('should build value filter', function () { const filter = buildFilter(searchFilters.value); const expectedFilter: ESTermFilter = { term: { 'type.raw': SCThingType.Dish, }, }; expect(filter).to.be.eql(expectedFilter); }); it('should build numeric range filters', function () { for (const upperMode of ['inclusive', 'exclusive', null]) { for (const lowerMode of ['inclusive', 'exclusive', null]) { const expectedFilter: ESNumericRangeFilter = { range: { price: { relation: undefined, }, }, }; const rawFilter: SCSearchNumericRangeFilter = { type: 'numeric range', arguments: { bounds: {}, field: 'price', }, }; // eslint-disable-next-line unicorn/consistent-function-scoping const setBound = (location: 'upperBound' | 'lowerBound', bound: string | null) => { let out: number | null = null; if (bound != undefined) { out = Math.random(); rawFilter.arguments.bounds[location] = { mode: bound as 'inclusive' | 'exclusive', limit: out, }; expectedFilter.range.price[ `${location === 'lowerBound' ? 'g' : 'l'}${bound === 'inclusive' ? 'te' : 't'}` ] = out; } }; setBound('upperBound', upperMode); setBound('lowerBound', lowerMode); const filter = buildFilter(rawFilter) as ESNumericRangeFilter; expect(filter).to.deep.equal(expectedFilter); for (const bound of ['g', 'l']) { // @ts-expect-error implicit any const inclusiveExists = typeof filter.range.price[`${bound}t`] !== 'undefined'; // @ts-expect-error implicit any const exclusiveExists = typeof filter.range.price[`${bound}te`] !== 'undefined'; // only one should exist at the same time expect(inclusiveExists && exclusiveExists).to.be.false; } } } }); it('should build date range filters', function () { for (const upperMode of ['inclusive', 'exclusive', null]) { for (const lowerMode of ['inclusive', 'exclusive', null]) { const expectedFilter: ESDateRangeFilter = { range: { price: { format: 'thisIsADummyFormat', time_zone: 'thisIsADummyTimeZone', relation: 'testRelation' as any, }, }, }; const rawFilter: SCSearchDateRangeFilter = { type: 'date range', arguments: { bounds: {}, field: 'price', relation: 'testRelation' as any, format: 'thisIsADummyFormat', timeZone: 'thisIsADummyTimeZone', }, }; const setBound = (location: 'upperBound' | 'lowerBound', bound: string | null) => { let out: string | null = null; if (bound != undefined) { out = `${location} ${bound} ${upperMode} ${lowerMode}`; rawFilter.arguments.bounds[location] = { mode: bound as 'inclusive' | 'exclusive', limit: out, }; expectedFilter.range.price[ `${location === 'lowerBound' ? 'g' : 'l'}${bound === 'inclusive' ? 'te' : 't'}` ] = out; } }; setBound('upperBound', upperMode); setBound('lowerBound', lowerMode); const filter = buildFilter(rawFilter) as ESNumericRangeFilter; expect(filter).to.deep.equal(expectedFilter); for (const bound of ['g', 'l']) { // @ts-expect-error implicit any const inclusiveExists = typeof filter.range.price[`${bound}t`] !== 'undefined'; // @ts-expect-error implicit any const exclusiveExists = typeof filter.range.price[`${bound}te`] !== 'undefined'; // only one should exist at the same time expect(inclusiveExists && exclusiveExists).to.be.false; } } } }); it('should build availability filters', function () { it('should copy scope', function () { for (const scope of ['a', 'b']) { const filter = buildFilter({ type: 'availability', arguments: { time: 'test', scope: scope as any, field: 'offers.availabilityRange', }, }); const expectedFilter: ESRangeFilter = { range: { 'offers.availabilityRange': { gte: `test||/${scope}`, lt: `test||+1${scope}/${scope}`, }, }, }; expect(filter).to.be.eql(expectedFilter); } }); it('should default to second scope', function () { const filter = buildFilter({ type: 'availability', arguments: { time: 'test', field: 'offers.availabilityRange', }, }); const expectedFilter: ESRangeFilter = { range: { 'offers.availabilityRange': { gte: 'test||/s', lt: 'test||+1s/s', }, }, }; expect(filter).to.be.eql(expectedFilter); }); it('should add || to dates', function () { const filter = buildFilter({ type: 'availability', arguments: { time: 'test', scope: 'd', field: 'offers.availabilityRange', }, }); const expectedFilter: ESRangeFilter = { range: { 'offers.availabilityRange': { gte: `test||/d`, lt: `test||+1d/d`, }, }, }; expect(filter).to.be.eql(expectedFilter); }); it('should default to now and not add ||', function () { const filter = buildFilter({ type: 'availability', arguments: { scope: 'd', field: 'offers.availabilityRange', }, }); const expectedFilter: ESRangeFilter = { range: { 'offers.availabilityRange': { gte: `now/d`, lt: `now+1d/d`, }, }, }; expect(filter).to.be.eql(expectedFilter); }); }); it('should build distance filter', function () { const filter = buildFilter(searchFilters.distance); const expectedFilter: ESGeoDistanceFilter = { geo_distance: { 'distance': '1000m', 'geo.point.coordinates': { lat: 8.123, lon: 50.123, }, }, }; expect(filter).to.be.eql(expectedFilter); }); it('should build geo filter for shapes and points', function () { const filter = buildFilter(searchFilters.geoPoint); const expectedFilter = { bool: { minimum_should_match: 1, should: [ { geo_shape: { 'geo.polygon': { relation: undefined, shape: { type: 'envelope', coordinates: [ [50.123, 8.123], [50.123, 8.123], ], }, }, 'ignore_unmapped': true, }, }, { geo_bounding_box: { 'geo.point.coordinates': { bottom_right: [50.123, 8.123], top_left: [50.123, 8.123], }, 'ignore_unmapped': true, }, }, ], }, }; expect(filter).to.be.eql(expectedFilter); }); it('should build geo filter for shapes only', function () { const filter = buildFilter(searchFilters.geoShape); const expectedFilter = { geo_shape: { 'geo.polygon': { relation: 'contains', shape: { type: 'envelope', coordinates: [ [50.123, 8.123], [50.123, 8.123], ], }, }, 'ignore_unmapped': true, }, }; expect(filter).to.be.eql(expectedFilter); }); it('should build boolean filter', function () { const filter = buildFilter(searchFilters.boolean); const expectedFilter: ESBooleanFilter = { bool: { minimum_should_match: 0, must: [ { term: { 'type.raw': 'dish', }, }, { range: { 'offers.availabilityRange': { gte: 'now/s', lt: 'now+1s/s', relation: 'intersects', }, }, }, ], must_not: [], should: [], }, }; expect(filter).to.be.eql(expectedFilter); }); }); describe('buildSort', function () { const searchSCSearchSort: Array = [ { type: 'ducet', order: 'desc', arguments: { field: 'name', }, }, { type: 'generic', order: 'desc', arguments: { field: 'name', }, }, { type: 'distance', order: 'desc', arguments: { field: 'geo', position: [8.123, 50.123], }, }, { type: 'price', order: 'asc', arguments: { universityRole: 'student', field: 'offers.prices', }, }, ]; let sorts: Array = []; const expectedSorts: {[key: string]: ESGenericSort | ESGeoDistanceSort | ScriptSort} = { ducet: { 'name.sort': 'desc', }, generic: { name: 'desc', }, distance: { _geo_distance: { 'mode': 'avg', 'order': 'desc', 'unit': 'm', 'geo.point.coordinates': { lat: 50.123, lon: 8.123, }, }, }, price: { _script: { order: 'asc', script: '\n // foo price sort script', type: 'number', }, }, }; before(function () { sorts = buildSort(searchSCSearchSort); }); it('should build ducet sort', function () { expect(sorts[0]).to.be.eql(expectedSorts.ducet); }); it('should build generic sort', function () { expect(sorts[1]).to.be.eql(expectedSorts.generic); }); it('should build distance sort', function () { expect(sorts[2]).to.be.eql(expectedSorts.distance); }); it('should build price sort', function () { const priceSortNoScript = { ...sorts[3], _script: { ...(sorts[3] as ScriptSort)._script, script: (expectedSorts.price as ScriptSort)._script.script, }, }; expect(priceSortNoScript).to.be.eql(expectedSorts.price); }); }); });