diff --git a/src/structures/interfaces/photon-response.interface.ts b/src/structures/interfaces/photon-response.interface.ts index 840ce7d3658360611ed89a6d17c6d7e4a309a335..8ac17237d8bcfea8d96ab0c7394f515f8f17d9a4 100644 --- a/src/structures/interfaces/photon-response.interface.ts +++ b/src/structures/interfaces/photon-response.interface.ts @@ -12,8 +12,11 @@ export interface PhotonPoints { osm_key: string; osm_value: string; name?: string; - state: string; + street?: string; + state?: string; city?: string; postcode?: string; + housenumber?: string; + type?: string; }; } diff --git a/src/structures/services/structures.service.spec.ts b/src/structures/services/structures.service.spec.ts index 47240ed532131095c8137d83ea475f6c45fd99d9..2b7305e86155a56c3d0aaa44d623f604804b96cd 100644 --- a/src/structures/services/structures.service.spec.ts +++ b/src/structures/services/structures.service.spec.ts @@ -10,7 +10,12 @@ import { personalOffersDataMock } from '../../../test/mock/data/personalOffers.m import { structureMockDto, structuresDocumentDataMock } from '../../../test/mock/data/structures.mock.data'; import { userDetails } from '../../../test/mock/data/users.mock.data'; import { mockParametersModel } from '../../../test/mock/services/parameters.mock.service'; -import { mockCNFSStructures, mockResinStructures } from '../../../test/mock/services/structures.mock.service'; +import { + mockCNFSStructures, + mockResinStructures, + mockSearchAdressBal, + mockSearchAdressBan, +} from '../../../test/mock/services/structures.mock.service'; import { UsersServiceMock } from '../../../test/mock/services/user.mock.service'; import { CategoriesService } from '../../categories/services/categories.service'; import { ConfigurationService } from '../../configuration/configuration.service'; @@ -611,4 +616,41 @@ describe('StructuresService', () => { expect(mockStructureModel.findByIdAndUpdate).toBeCalled(); }); }); + + describe('searchAddress', () => { + beforeEach(() => { + httpServiceMock.get.mockClear(); + httpServiceMock.get + .mockImplementationOnce(() => of({ data: mockSearchAdressBan })) + .mockImplementationOnce(() => of({ data: mockSearchAdressBal })); + }); + + it('should return only one result for duplicate addresses and addresses with diacritical marks', async () => { + const data = { searchQuery: 'rue édison' }; + const expectedResult = { + features: [ + { + geometry: { coordinates: [4.993358, 45.7753763], type: 'Point' }, + type: 'Feature', + properties: { + osm_id: 13529, + osm_type: 'N', + country: 'France', + osm_key: 'place', + housenumber: '20', + city: 'Meyzieu', + street: 'Rue Edison', + osm_value: 'house', + postcode: '69330', + type: 'house', + }, + }, + ], + }; + const result = await service.searchAddress(data); + + expect(httpServiceMock.get).toHaveBeenCalledTimes(2); + expect(result).toEqual(expectedResult); + }); + }); }); diff --git a/src/structures/services/structures.service.ts b/src/structures/services/structures.service.ts index c9c2d9b4e580312ddc2c189883209ebf3ad53f37..a60a21343c9a4a17fd4a43e6db751840cde12104 100644 --- a/src/structures/services/structures.service.ts +++ b/src/structures/services/structures.service.ts @@ -27,6 +27,7 @@ import { CNFSStructure } from '../interfaces/cnfs-structure.interface'; import { Address } from '../schemas/address.schema'; import { Structure, StructureDocument } from '../schemas/structure.schema'; import { StructuresSearchService } from './structures-search.service'; +import { PhotonPoints } from '../interfaces/photon-response.interface'; @Injectable() export class StructuresService { @@ -365,7 +366,6 @@ export class StructuresService { * Get structures positions and add marker corresponding to those positons on the map */ private async getStructurePosition(structure: Structure): Promise<Structure> { - this.logger.debug('getStructurePosition'); try { let address: { geometry: { @@ -491,64 +491,55 @@ export class StructuresService { * Search structure address based on data search WS * @param {searchQuery} data - Query address */ - public async searchAddress(data: { - searchQuery: string; - }): Promise<{ features: { geometry: { coordinates: number[] }; type: string; properties: unknown }[] }> { + public async searchAddress(data: { searchQuery: string }): Promise<{ features: PhotonPoints[] }> { const reqBan = `https://download.data.grandlyon.com/geocoding/photon/api?q=${data.searchQuery}&lang=fr&limit=500&osm_tag=:!construction&osm_tag=:!bus_stop`; - const reqBal = `https://download.data.grandlyon.com/geocoding/photon-bal/api?q=${data.searchQuery}&lang=fr&limit=500&osm_tag=:!construction&osm_tag=:!bus_stop`; - const requestGroup = ( - url - ): Promise<{ features: { geometry: { coordinates: number[] }; type: string; properties: unknown }[] }> => - new Promise((resolve) => { - this.logger.debug(`Search request: ${encodeURI(url)}`, 'StructureService'); - return this.httpService - .request({ - url: encodeURI(url), - method: 'GET', - headers: { 'Content-Type': 'application/json' }, - }) - .subscribe( - (reply) => { - this.logger.debug(`Search request response length : ${reply.data.features.length}`, 'StructureService'); - reply.data.features = reply.data.features - .filter((doc) => doc.properties.postcode && doc.properties.postcode.match(depRegex)) - .sort((_a, b) => { - return b.properties.housenumber ? 1 : -1; - }); - return resolve(reply.data); - }, - (err) => { - this.logger.error(`Search - Request error: ${err.config.url}`, 'StructureService'); - this.logger.error(err); - } - ); + const requestGroup = async (url: string): Promise<AxiosResponse<{ features: PhotonPoints[] }>> => { + const response$ = this.httpService.get<{ features: PhotonPoints[] }>(encodeURI(url), { + headers: { 'Content-Type': 'application/json' }, }); + const response = await lastValueFrom(response$); + response.data.features = response.data.features + .filter((doc) => doc.properties.postcode && doc.properties.postcode.match(depRegex)) + .sort((a, b) => (b.properties.housenumber ? 1 : -1)); + + return response; + }; + const [reqBanRes, reqBalRes] = await Promise.all([requestGroup(reqBan), requestGroup(reqBal)]); - const mergedArray = [...reqBalRes['features'], ...reqBanRes['features']]; - const duplicateFreeArray = _.unionWith(mergedArray, function (a, b) { + const mergedArray = [...reqBalRes.data.features, ...reqBanRes.data.features]; + + const duplicateFreeArray = _.unionWith(mergedArray, (a, b) => { + /** + * Remove accents from a string by decomposing accented characters and removing diacritical marks. + * @param {string} str - The input string. + * @returns {string} - The string with accents removed. + */ + const removeAccents = (str: string) => { + if (!str) return ''; + // Decompose accented characters into base characters and diacritical marks, then remove the diacritical marks using a regex pattern. + return str.normalize('NFD').replace(/[\u0300-\u036f]/g, ''); + }; return ( - // excludes structures from photon-BAN if they share postcode && street or name && house number - // checking for name and street existence asserts that we will not compare 2 undefined fields a.properties.postcode === b.properties.postcode && ((a.properties.name && - (a.properties.name === b.properties.name || a.properties.name === b.properties.street)) || + (removeAccents(a.properties.name) === removeAccents(b.properties.name) || + removeAccents(a.properties.name) === removeAccents(b.properties.street))) || (a.properties.street && - (a.properties.street === b.properties.name || a.properties.street === b.properties.street))) && + (removeAccents(a.properties.street) === removeAccents(b.properties.name) || + removeAccents(a.properties.street) === removeAccents(b.properties.street)))) && (a.properties.housenumber === b.properties.housenumber || (!a.properties.housenumber && !b.properties.housenumber)) ); }); - duplicateFreeArray.forEach((features) => { - features.properties.city = this.getFormattedCity(features.properties.city, features.properties.postcode); + duplicateFreeArray.forEach((feature) => { + feature.properties.city = this.getFormattedCity(feature.properties.city, feature.properties.postcode); }); - return { - features: duplicateFreeArray, - }; + return { features: duplicateFreeArray }; } /** diff --git a/test/mock/services/structures.mock.service.ts b/test/mock/services/structures.mock.service.ts index 6c03ecafd9889e6b1de1d18442d476d64cbeeaa3..c17451feb54318dd9548997aab3f8319e5d5c250 100644 --- a/test/mock/services/structures.mock.service.ts +++ b/test/mock/services/structures.mock.service.ts @@ -3,6 +3,7 @@ import { Types } from 'mongoose'; import { PersonalOfferDocument } from '../../../src/personal-offers/schemas/personal-offer.schema'; import { CNFSStructure } from '../../../src/structures/interfaces/cnfs-structure.interface'; import { Structure, StructureDocument } from '../../../src/structures/schemas/structure.schema'; +import { PhotonPoints } from '../../../src/structures/interfaces/photon-response.interface'; export class StructuresServiceMock { findOne(id: Types.ObjectId) { @@ -1344,6 +1345,48 @@ export class StructuresServiceMock { } } +export const mockSearchAdressBan: { features: PhotonPoints[] } = { + features: [ + { + geometry: { coordinates: [4.993358, 45.7753763], type: 'Point' }, + type: 'Feature', + properties: { + osm_id: 2840376300, + osm_type: 'N', + country: 'France', + osm_key: 'place', + housenumber: '20', + city: 'Meyzieu', + street: 'Rue Édison', + osm_value: 'house', + postcode: '69330', + state: 'Auvergne-Rhône-Alpes', + }, + }, + ], +}; + +export const mockSearchAdressBal: { features: PhotonPoints[] } = { + features: [ + { + geometry: { coordinates: [4.993358, 45.7753763], type: 'Point' }, + type: 'Feature', + properties: { + osm_id: 13529, + osm_type: 'N', + country: 'France', + osm_key: 'place', + housenumber: '20', + city: 'Meyzieu', + street: 'Rue Edison', + osm_value: 'house', + postcode: '69330', + type: 'house', + }, + }, + ], +}; + export const mockCNFSStructures: Array<CNFSStructure> = [ { id: '62866838cdc04606eb14ee90',