/* 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 {buildFilter} from '../../../src/storage/elasticsearch/query/filter'; import {buildBooleanFilter} from '../../../src/storage/elasticsearch/query/filters/boolean'; import {buildQuery} from '../../../src/storage/elasticsearch/query/query'; import {buildSort} from '../../../src/storage/elasticsearch/query/sort'; import {ElasticsearchConfig} from '../../../src/storage/elasticsearch/types/elasticsearch-config'; import {QueryDslSpecificQueryContainer} from '../../../src/storage/elasticsearch/types/util'; import {configFile} from '../../../src/common'; import {SortCombinations} from '@elastic/elasticsearch/lib/api/types'; 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).bool; expect(must).to.be.eql(expectedEsFilters); }); it('should create appropriate elasticsearch "or" filter argument', function () { const {should, minimum_should_match} = buildBooleanFilter(booleanFilters.or).bool; 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).bool; 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'); }); it('should accept other search contexts', function () { expect(buildQuery({context: 'place', ...parameters}, config, esConfig)).to.be.an('object'); }); }); 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: QueryDslSpecificQueryContainer<'term'> = { 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: QueryDslSpecificQueryContainer<'range'> = { 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 QueryDslSpecificQueryContainer<'term'>; 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: QueryDslSpecificQueryContainer<'range'> = { 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 QueryDslSpecificQueryContainer<'range'>; 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: QueryDslSpecificQueryContainer<'range'> = { 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: QueryDslSpecificQueryContainer<'range'> = { 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: QueryDslSpecificQueryContainer<'range'> = { 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: QueryDslSpecificQueryContainer<'range'> = { 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: QueryDslSpecificQueryContainer<'geo_distance'> = { 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 = { geo_shape: { 'geo.polygon': { relation: undefined, shape: { type: 'envelope', coordinates: [ [50.123, 8.123], [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: QueryDslSpecificQueryContainer<'bool'> = { 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: SortCombinations[] = []; const expectedSorts: {[key: string]: SortCombinations} = { 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) as SortCombinations[]; }); 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] as any), _script: { ...(sorts[3] as any)._script, script: (expectedSorts.price as any)._script.script, }, }; expect(priceSortNoScript).to.be.eql(expectedSorts.price); }); }); });