diff --git a/.gitignore b/.gitignore index db58f04b..943c2001 100644 --- a/.gitignore +++ b/.gitignore @@ -110,3 +110,6 @@ dist # Jest jest_html_reporters.html reports + +# for MacOS Developers +.DS_Store diff --git a/config/default.json b/config/default.json index 6218fe53..ef8c0e52 100644 --- a/config/default.json +++ b/config/default.json @@ -44,7 +44,7 @@ "host": "localhost", "port": 5432, "username": "postgres", - "password": "test1234", + "password": "postgres", "enableSslAuth": false, "sslPaths": { "ca": "", diff --git a/config/test.json b/config/test.json index 0967ef42..3654706c 100644 --- a/config/test.json +++ b/config/test.json @@ -1 +1,32 @@ -{} +{ + "db": { + "elastic": { + "node": "http://localhost:9200", + "auth": { + "username": "elastic", + "password": "changeme" + }, + "requestTimeout": 60000, + "properties": { + "controlIndex": "geocoder_control_tests", + "nlpIndex": "geocoder_control_tests", + "size": 3 + } + }, + "postgresql": { + "type": "postgres", + "host": "localhost", + "port": 5432, + "username": "postgres", + "password": "postgres", + "enableSslAuth": false, + "sslPaths": { + "ca": "", + "key": "", + "cert": "" + }, + "database": "postgres", + "schema": "geocoder_test" + } + } +} diff --git a/dataSource.ts b/dataSource.ts new file mode 100644 index 00000000..ad31f0f6 --- /dev/null +++ b/dataSource.ts @@ -0,0 +1,13 @@ +import config from 'config'; +import { DataSource } from 'typeorm'; +import { createConnectionOptions } from './src/common/postgresql'; +import { PostgresDbConfig } from './src/common/interfaces'; + +const connectionOptions = config.get('db.postgresql'); + +export const appDataSource = new DataSource({ + ...createConnectionOptions(connectionOptions), + entities: ['src/**/DAL/*.ts'], + migrationsTableName: 'migrations_table', + migrations: ['db/migrations/*.ts'], +}); diff --git a/devScripts/elasticsearchData.json b/devScripts/elasticsearchData.json new file mode 100644 index 00000000..856ca077 --- /dev/null +++ b/devScripts/elasticsearchData.json @@ -0,0 +1,145 @@ +[ + { + "_id": "CONTROL.ITEMS", + "_score": 1, + "_source": { + "type": "Feature", + "id": 27, + "geometry": { + "coordinates": [ + [ + [98.96871358832425, 18.77187541003238], + [98.96711613001014, 18.772012912146167], + [98.9668257091372, 18.77957500633211], + [98.96517988589727, 18.783516234000658], + [98.96305002071, 18.786174113473763], + [98.96222710028087, 18.786036643850125], + [98.9615009529004, 18.784478609414165], + [98.96096850521184, 18.78324114944766], + [98.96058105300438, 18.74932410944021], + [98.96029062202649, 18.747169677704846], + [98.9619364723165, 18.74666541646775], + [98.96479250629358, 18.7514784826093], + [98.96401797557468, 18.75193687161918], + [98.96614791571238, 18.754412116891245], + [98.96639000300826, 18.75940841151673], + [98.96779381973744, 18.759133392649602], + [98.96798745662056, 18.76018763174328], + [98.96789063809456, 18.761746062357858], + [98.96905242991687, 18.763487833908357], + [98.96871358832425, 18.77187541003238] + ] + ], + "type": "Polygon" + }, + "properties": { + "OBJECTID": 27, + "F_CODE": 2300, + "F_ATT": 602, + "VIEW_SCALE_50K_CONTROL": 604, + "NAME": null, + "GFID": "{93C8C8E2-6AAB-4A84-BBF0-5B10E6F90938}", + "OBJECT_COMMAND_NAME": "4805", + "SUB_TILE_NAME": null, + "TILE_NAME": "DEF", + "TILE_ID": "36, 7300, 3560", + "SUB_TILE_ID": "36", + "SHAPE.AREA": 2.65, + "SHAPE.LEN": 0.0044192097656775, + "LAYER_NAME": "CONTROL.ITEMS", + "ENTITY_HEB": "airport", + "TYPE": "ITEM" + } + } + }, + { + "_id": "CONTROL.SUB_TILES", + "_score": 1, + "_source": { + "type": "Feature", + "id": 13668, + "geometry": { + "coordinates": [ + [ + [27.149158174343427, 35.63159611670335], + [27.149274355343437, 35.64061707270338], + [27.138786228343463, 35.640716597703374], + [27.13867103934342, 35.631695606703374], + [27.149158174343427, 35.63159611670335] + ] + ], + "type": "Polygon" + }, + "properties": { + "OBJECTID": 13668, + "SUB_TILE_ID": "65", + "TILE_NAME": "GRC", + "NAME": "somePlace", + "ZON": 35, + "LAYER_NAME": "CONTROL.SUB_TILES", + "TYPE": "SUB_TILE" + } + } + }, + { + "_id": "CONTROL.ROUTES", + "_score": 1, + "_source": { + "type": "Feature", + "id": 2, + "geometry": { + "coordinates": [ + [13.448493352142947, 52.31016611400918], + [13.447219581381603, 52.313370282889224], + [13.448088381125075, 52.31631514453963], + [13.450458681234068, 52.31867376333767], + [13.451112278530388, 52.32227665244022], + [13.449728938644029, 52.32463678850752], + [13.445021899434977, 52.32863442881066], + [13.444723882330948, 52.340023400115086], + [13.446229682887974, 52.34532799609971] + ], + "type": "LineString" + }, + "properties": { + "OBJECTID": 2, + "OBJECT_COMMAND_NAME": "route96", + "FIRST_GFID": null, + "SHAPE_Length": 4.1, + "F_CODE": 8754, + "F_ATT": 951, + "LAYER_NAME": "CONTROL.ROUTES", + "ENTITY_HEB": "route96", + "TYPE": "ROUTE" + } + } + }, + { + "_id": "CONTROL.TILES", + "_score": 1, + "_source": { + "type": "Feature", + "id": 52, + "geometry": { + "coordinates": [ + [ + [12.539507865186607, 41.851751203650096], + [12.536787075186538, 41.94185043165008], + [12.42879133518656, 41.93952837265009], + [12.431625055186686, 41.84943698365008], + [12.539507865186607, 41.851751203650096] + ] + ], + "type": "Polygon" + }, + "properties": { + "OBJECTID": 52, + "ZONE": "37", + "TILE_NAME": "RIT", + "TILE_ID": null, + "LAYER_NAME": "CONTROL.TILES", + "TYPE": "TILE" + } + } + } +] diff --git a/devScripts/importDataToElastic.ts b/devScripts/importDataToElastic.ts new file mode 100644 index 00000000..9836e52d --- /dev/null +++ b/devScripts/importDataToElastic.ts @@ -0,0 +1,20 @@ +/* eslint-disable @typescript-eslint/no-unsafe-assignment */ +/* eslint-disable @typescript-eslint/naming-convention */ +/* eslint-disable @typescript-eslint/no-unsafe-member-access */ +import { Client } from '@elastic/elasticsearch'; +import config from '../config/test.json'; +import data from './elasticsearchData.json'; + +const main = async (): Promise => { + const client = new Client({ node: config.db.elastic.node as string }); + + for (const item of data) { + await client.index({ + index: config.db.elastic.properties.controlIndex as string, + id: item._id as string, + body: item._source, + }); + } +}; + +export default main; diff --git a/devScripts/importToPostgres.ts b/devScripts/importToPostgres.ts new file mode 100644 index 00000000..1580282a --- /dev/null +++ b/devScripts/importToPostgres.ts @@ -0,0 +1,45 @@ +import { DataSource } from 'typeorm'; +import { createConnectionOptions } from '../src/common/postgresql'; +import config from '../config/test.json'; +import { PostgresDbConfig } from '../src/common/interfaces'; + +export default async function createDatabaseClient(): Promise { + const connectionOptions = config.db.postgresql as PostgresDbConfig; + const connection = new DataSource({ + ...createConnectionOptions(connectionOptions), + }); + + await connection.initialize(); + + await connection.query(` + CREATE TABLE IF NOT EXISTS ${config.db.postgresql.schema}.tile_lat_lon + ( + pk integer NOT NULL, + tile_name text COLLATE pg_catalog."default", + zone text COLLATE pg_catalog."default", + min_x text COLLATE pg_catalog."default", + min_y text COLLATE pg_catalog."default", + ext_min_x integer, + ext_min_y integer, + ext_max_x integer, + ext_max_y integer, + CONSTRAINT tile_lat_lon_pkey PRIMARY KEY (pk) + ) + `); + + await connection.query(` + INSERT INTO ${config.db.postgresql.schema}.tile_lat_lon( + pk, tile_name, zone, min_x, min_y, ext_min_x, ext_min_y, ext_max_x, ext_max_y) + VALUES (1, 'BRN', '33', '360000', '5820000', 360000, 5820000, 370000, 5830000); + `); + + await connection.query(` + INSERT INTO ${config.db.postgresql.schema}.tile_lat_lon( + pk, tile_name, zone, min_x, min_y, ext_min_x, ext_min_y, ext_max_x, ext_max_y) + VALUES (2, 'BMN', '32', '480000', '5880000', 480000, 5880000, 490000, 5890000); + `); + + console.log('PGSQL: Table created and data inserted'); + + await connection.destroy(); +} diff --git a/devScripts/index.ts b/devScripts/index.ts new file mode 100644 index 00000000..1ec5acdc --- /dev/null +++ b/devScripts/index.ts @@ -0,0 +1,10 @@ +import importDataToElastic from './importDataToElastic'; +import importToPostgres from './importToPostgres'; + +importDataToElastic() + .then(() => console.log('Success import data to elastic')) + .catch(console.error); + +importToPostgres() + .then(() => console.log('Success import data to postgres')) + .catch(console.error); diff --git a/openapi3.yaml b/openapi3.yaml index e56149f3..32a381dd 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -95,7 +95,7 @@ paths: content: application/json: schema: - oneOf: + anyOf: - $ref: "#/components/schemas/tilesSchema" - $ref: "#/components/schemas/routesSchema" - $ref: "#/components/schemas/itemsSchema" @@ -226,7 +226,7 @@ paths: content: application/json: schema: - oneOf: + anyOf: - $ref: "#/components/schemas/routesSchema" - $ref: "#/components/schemas/itemsSchema" 400: @@ -396,7 +396,12 @@ paths: content: application/json: schema: - $ref: "#/components/schemas/tilesSchema" + type: object + properties: + lat: + type: number + lon: + type: number 400: "$ref": "#/components/responses/BadRequest" 401: @@ -499,6 +504,9 @@ components: items: type: "object" properties: + type: + type: "string" + enum: ["Feature"] geometry: $ref: "#/components/schemas/Geometry" properties: diff --git a/package.json b/package.json index 9495eec4..f2dd2c26 100644 --- a/package.json +++ b/package.json @@ -21,7 +21,12 @@ "start:dev": "npm run build && cd dist && node --enable-source-maps ./index.js", "assets:copy": "copyfiles -f ./config/* ./dist/config && copyfiles -f ./openapi3.yaml ./dist/ && copyfiles ./package.json dist", "clean": "rimraf dist", - "install": "npx husky install" + "install": "npx husky install", + "dev:scripts": "npx ts-node ./devScripts/index.ts", + "typeorm": "node --require ts-node/register ./node_modules/typeorm/cli.js -d dataSource.ts", + "migration:create": "npm run typeorm migration:generate", + "migration:run": "npm run typeorm migration:run", + "migration:revert": "npm run typeorm migration:revert" }, "directories": { "test": "tests" diff --git a/src/common/interfaces.ts b/src/common/interfaces.ts index fad5959a..7d98367f 100644 --- a/src/common/interfaces.ts +++ b/src/common/interfaces.ts @@ -1,5 +1,6 @@ import { ClientOptions } from '@elastic/elasticsearch'; import { DataSourceOptions } from 'typeorm'; +import { Feature, FeatureCollection as GeoJSONFeatureCollection } from 'geojson'; export interface IConfig { get: (setting: string) => T; @@ -31,3 +32,7 @@ export interface Geometry { type: string; coordinates: number[][][]; } + +export interface FeatureCollection extends GeoJSONFeatureCollection { + features: T[]; +} diff --git a/src/common/utils.ts b/src/common/utils.ts index d398e765..e618aa7c 100644 --- a/src/common/utils.ts +++ b/src/common/utils.ts @@ -6,27 +6,23 @@ import { Tile } from '../tile/models/tile'; import { Route } from '../route/models/route'; import { FIELDS } from './constants'; import { utmProjection, wgs84Projection } from './projections'; +import { FeatureCollection } from './interfaces'; -export const formatResponse = ( - elasticResponse: estypes.SearchResponse -): { - type: string; - features: (T | undefined)[]; -} => ({ +export const formatResponse = (elasticResponse: estypes.SearchResponse): FeatureCollection => ({ type: 'FeatureCollection', features: [ - ...elasticResponse.hits.hits.map((item) => { + ...(elasticResponse.hits.hits.map((item) => { const source = item._source; - if (source) { + if (source?.properties) { Object.keys(source.properties).forEach((key) => { // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition - if (source.properties[key as keyof typeof source.properties] == null) { + if (source.properties !== null && source.properties[key as keyof typeof source.properties] == null) { delete source.properties[key as keyof typeof source.properties]; } }); } return source; - }), + }) as T[]), ], }); diff --git a/src/item/controllers/itemController.ts b/src/item/controllers/itemController.ts index 716f707a..a06d2457 100644 --- a/src/item/controllers/itemController.ts +++ b/src/item/controllers/itemController.ts @@ -16,16 +16,18 @@ type GetResourceHandler = RequestHandler< features: (Item | undefined)[]; }, undefined, - { - command_name: string; - tile?: string; - sub_tile?: string; - geo_context?: string; - reduce_fuzzy_match?: string; - size?: string; - } + GetItemsQueryParams >; +export interface GetItemsQueryParams { + command_name: string; + tile?: string; + sub_tile?: string; + geo_context?: string; + reduce_fuzzy_match?: string; + size?: string; +} + @injectable() export class ItemController { private readonly createdResourceCounter: BoundCounter; diff --git a/src/item/models/item.ts b/src/item/models/item.ts index ed8a5328..3869c449 100644 --- a/src/item/models/item.ts +++ b/src/item/models/item.ts @@ -1,28 +1,3 @@ -import { Geometry } from '../../common/interfaces'; +import { Feature } from 'geojson'; -/* eslint-disable @typescript-eslint/naming-convention */ -interface Properties { - OBJECTID: number; - F_CODE: number; - F_ATT: number; - VIEW_SCALE_50K_CONTROL: number; - NAME: null; // TODO: Check if this is correct - GFID: string; - OBJECT_COMMAND_NAME: string; - SUB_TILE_NAME: null; // TODO: Check if this is correct - TILE_NAME: string; - TILE_ID: null | string; - SUB_TILE_ID?: string; - 'SHAPE.AREA': number; - 'SHAPE.LEN': number; - LAYER_NAME: string; - ENTITY_HEB: string; - TYPE: string; -} - -export interface Item { - type: string; - id: number; - geometry: Geometry; - properties: Properties; -} +export interface Item extends Feature {} diff --git a/src/item/models/itemManager.ts b/src/item/models/itemManager.ts index 421e8e90..7833e7de 100644 --- a/src/item/models/itemManager.ts +++ b/src/item/models/itemManager.ts @@ -6,6 +6,7 @@ import { SERVICES } from '../../common/constants'; import { ITEM_REPOSITORY_SYMBOL, ItemRepository } from '../DAL/itemRepository'; import { ItemQueryParams } from '../DAL/queries'; import { formatResponse } from '../../common/utils'; +import { FeatureCollection } from '../../common/interfaces'; import { Item } from './item'; @injectable() @@ -16,21 +17,14 @@ export class ItemManager { @inject(ITEM_REPOSITORY_SYMBOL) private readonly itemRepository: ItemRepository ) {} - public async getItems( - itemQueryParams: ItemQueryParams, - reduceFuzzyMatch = false, - size?: number - ): Promise<{ - type: string; - features: (Item | undefined)[]; - }> { + public async getItems(itemQueryParams: ItemQueryParams, reduceFuzzyMatch = false, size?: number): Promise> { let elasticResponse: estypes.SearchResponse | undefined = undefined; elasticResponse = await this.itemRepository.getItems(itemQueryParams, size ?? this.config.get('db.elastic.properties.size')); const formattedResponse = formatResponse(elasticResponse); if (reduceFuzzyMatch && formattedResponse.features.length > 0) { - const filterFunction = (hit: Item | undefined): hit is Item => hit?.properties.OBJECT_COMMAND_NAME === itemQueryParams.commandName; + const filterFunction = (hit: Item | undefined): hit is Item => hit?.properties?.OBJECT_COMMAND_NAME === itemQueryParams.commandName; formattedResponse.features = formattedResponse.features.filter(filterFunction); } diff --git a/src/latLon/controllers/latLonController.ts b/src/latLon/controllers/latLonController.ts index 95c8208e..ae1ca70f 100644 --- a/src/latLon/controllers/latLonController.ts +++ b/src/latLon/controllers/latLonController.ts @@ -4,62 +4,54 @@ import { BoundCounter, Meter } from '@opentelemetry/api-metrics'; import { RequestHandler } from 'express'; import httpStatus from 'http-status-codes'; import { injectable, inject } from 'tsyringe'; -import { Polygon } from 'geojson'; import { SERVICES } from '../../common/constants'; import { LatLonManager } from '../models/latLonManager'; +import { Tile } from '../../tile/models/tile'; +import { FeatureCollection } from '../../common/interfaces'; type GetLatLonToTileHandler = RequestHandler< undefined, { tileName: string; - subTileNumber: string[]; + subTileNumber: number[]; }, undefined, - { - lat: number; - lon: number; - } + GetLatLonToTileQueryParams >; -type GetTileToLatLonHandler = RequestHandler< - undefined, - { - geometry: Polygon; - type: string; - properties: { - TYPE: string; - SUB_TILE_NUMBER?: number[] | undefined; - TILE_NAME?: string | undefined; - }; - }, - undefined, - { - tile: string; - sub_tile_number: number[]; - } ->; +type GetTileToLatLonHandler = RequestHandler, undefined, GetTileToLatLonQueryParams>; -type GetLatLonToMgrsHandler = RequestHandler< - undefined, - { mgrs: string }, - undefined, - { - lat: number; - lon: number; - accuracy?: number; - } ->; +type GetLatLonToMgrsHandler = RequestHandler; -type getMgrsToLatLonHandler = RequestHandler< +type GetMgrsToLatLonHandler = RequestHandler< undefined, { lat: number; lon: number; }, undefined, - { mgrs: string } + GetMgrsToLatLonQueryParams >; +export interface GetLatLonToTileQueryParams { + lat: number; + lon: number; +} + +export interface GetTileToLatLonQueryParams { + tile: string; + sub_tile_number: number[]; +} + +export interface GetLatLonToMgrsQueryParams { + lat: number; + lon: number; + accuracy?: number; +} +export interface GetMgrsToLatLonQueryParams { + mgrs: string; +} + @injectable() export class LatLonController { private readonly createdResourceCounter: BoundCounter; @@ -112,7 +104,7 @@ export class LatLonController { } }; - public mgrsToLatlon: getMgrsToLatLonHandler = (req, res, next) => { + public mgrsToLatlon: GetMgrsToLatLonHandler = (req, res, next) => { try { const { mgrs } = req.query; diff --git a/src/latLon/models/latLonManager.ts b/src/latLon/models/latLonManager.ts index 4093b291..cbe322e5 100644 --- a/src/latLon/models/latLonManager.ts +++ b/src/latLon/models/latLonManager.ts @@ -2,12 +2,13 @@ import { IConfig } from 'config'; import { Logger } from '@map-colonies/js-logger'; import { inject, injectable } from 'tsyringe'; import * as mgrs from 'mgrs'; -import { Polygon } from 'geojson'; import { SERVICES } from '../../common/constants'; import { LATLON_CUSTOM_REPOSITORY_SYMBOL, LatLonRepository } from '../DAL/latLonRepository'; import { convertWgs84ToUTM, validateTile, validateWGS84Coordinate } from '../../common/utils'; import { convertTilesToUTM, getSubTileByBottomLeftUtmCoor, validateResult } from '../utlis'; -import { BadRequestError, NotFoundError } from '../../common/errors'; +import { BadRequestError } from '../../common/errors'; +import { Tile } from '../../tile/models/tile'; +import { FeatureCollection } from '../../common/interfaces'; @injectable() export class LatLonManager { @@ -23,7 +24,7 @@ export class LatLonManager { public async latLonToTile({ lat, lon }: { lat: number; lon: number }): Promise<{ tileName: string; - subTileNumber: string[]; + subTileNumber: number[]; }> { if (!validateWGS84Coordinate({ lat, lon })) { this.logger.warn("LatLonManager.latLonToTile: Invalid lat lon, check 'lat' and 'lon' keys exists and their values are legal"); @@ -60,36 +61,24 @@ export class LatLonManager { return { tileName: tileCoordinateData.tileName, subTileNumber: new Array(3).fill('').map(function (value, i) { - return xNumber[i] + yNumber[i]; + return +(xNumber[i] + yNumber[i]); }), }; } - public async tileToLatLon({ tileName, subTileNumber }: { tileName: string; subTileNumber: number[] }): Promise<{ - geometry: Polygon; - type: string; - properties: { - /* eslint-disable @typescript-eslint/naming-convention */ - TYPE: string; - SUB_TILE_NUMBER?: number[] | undefined; - TILE_NAME?: string | undefined; - /* eslint-enable @typescript-eslint/naming-convention */ - }; - }> { + public async tileToLatLon({ tileName, subTileNumber }: { tileName: string; subTileNumber: number[] }): Promise> { if (!validateTile({ tileName, subTileNumber })) { - this.logger.warn( - "LatLonManager.tileToLatLon: Invalid tile, check that 'tileName' and 'subTileNumber' exists and subTileNumber is array of size 3 with positive integers" - ); - throw new BadRequestError( - "Invalid tile, check that 'tileName' and 'subTileNumber' exists and subTileNumber is array of size 3 with positive integers" - ); + const message = "Invalid tile, check that 'tileName' and 'subTileNumber' exists and subTileNumber is array of size 3 with positive integers"; + this.logger.warn(`LatLonManager.tileToLatLon: ${message}`); + throw new BadRequestError(message); } const tile = await this.latLonRepository.tileToLatLon(tileName); if (!tile) { - this.logger.warn('LatLonManager.tileToLatLon: Tile not found'); - throw new NotFoundError('Tile not found'); + const meessage = 'Tile not found'; + this.logger.warn(`LatLonManager.tileToLatLon: ${meessage}`); + throw new BadRequestError(meessage); } const utmCoor = convertTilesToUTM({ tileName, subTileNumber }, tile); diff --git a/src/latLon/utlis/index.ts b/src/latLon/utlis/index.ts index 00b542ca..ae6e67de 100644 --- a/src/latLon/utlis/index.ts +++ b/src/latLon/utlis/index.ts @@ -1,28 +1,25 @@ import { Polygon } from 'geojson'; -import { NotFoundError } from '../../common/errors'; +import { BadRequestError } from '../../common/errors'; import { convertUTMToWgs84 } from '../../common/utils'; import { LatLon } from '../DAL/latLon'; +import { FeatureCollection } from '../../common/interfaces'; +import { Tile } from '../../tile/models/tile'; /* eslint-disable @typescript-eslint/naming-convention */ -const geoJsonObjectTemplate = (): { - geometry: Polygon; - type: string; - properties: { - TYPE: string; - SUB_TILE_NUMBER?: number[]; - TILE_NAME?: string; - }; -} => ({ - geometry: { - coordinates: [[]], - type: 'Polygon', - }, - type: 'Feature', - properties: { - TYPE: 'TILE', - SUB_TILE_NUMBER: undefined, - TILE_NAME: undefined, - }, +const geoJsonObjectTemplate = (): FeatureCollection => ({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + type: 'Polygon', + coordinates: [[]], + }, + properties: { + TYPE: 'TILE', + }, + }, + ], }); /* eslint-enable @typescript-eslint/naming-convention */ @@ -60,7 +57,7 @@ export const validateResult = ( } ): void => { if (tile.extMinX > utmCoor.x || tile.extMaxX < utmCoor.x || tile.extMinY > utmCoor.y || tile.extMaxY < utmCoor.y) { - throw new NotFoundError("Tile is found, sub tile is not in tile's extent"); + throw new BadRequestError("Tile is found, sub tile is not in tile's extent"); } }; @@ -71,8 +68,9 @@ export const getSubTileByBottomLeftUtmCoor = ( zone: number; }, tile: { tileName: string; subTileNumber: number[] } -) => { +): FeatureCollection => { const result = geoJsonObjectTemplate(); + const multiplyByOrder = [ [0, 0], [1, 0], @@ -81,16 +79,22 @@ export const getSubTileByBottomLeftUtmCoor = ( [0, 0], ]; // bottom left -> bottom right -> top right -> top left -> bottom left const distance = 10; // 10 meters + const polygon: Polygon = { + type: 'Polygon', + coordinates: [[]], + }; + for (const multiply of multiplyByOrder) { const coordiante = convertUTMToWgs84(utmCoor.x + distance * multiply[0], utmCoor.y + distance * multiply[1], utmCoor.zone); if (typeof coordiante === 'string') { throw new Error('coordinate is string'); } - result.geometry.coordinates[0].push([coordiante.lng, coordiante.lat]); + polygon.coordinates[0].push([coordiante.lng, coordiante.lat]); } - result.properties.TILE_NAME = tile.tileName; - result.properties.SUB_TILE_NUMBER = tile.subTileNumber; + result.features[0].geometry = polygon; + // eslint-disable-next-line @typescript-eslint/naming-convention + result.features[0].properties = { ...result.features[0].properties, TILE_NAME: tile.tileName, SUB_TILE_NUMBER: tile.subTileNumber }; return result; }; diff --git a/src/route/controllers/routeController.ts b/src/route/controllers/routeController.ts index a1155185..2232fde4 100644 --- a/src/route/controllers/routeController.ts +++ b/src/route/controllers/routeController.ts @@ -17,15 +17,17 @@ type GetResourceHandler = RequestHandler< features: (Route | undefined)[]; }, undefined, - { - command_name: string; - control_point?: string; - geo_context?: string; - reduce_fuzzy_match?: string; - size?: string; - } + GetRoutesQueryParams >; +export interface GetRoutesQueryParams { + command_name: string; + control_point?: string; + geo_context?: string; + reduce_fuzzy_match?: string; + size?: string; +} + @injectable() export class RouteController { private readonly createdResourceCounter: BoundCounter; diff --git a/src/route/models/route.ts b/src/route/models/route.ts index 1ac3133f..4860b131 100644 --- a/src/route/models/route.ts +++ b/src/route/models/route.ts @@ -1,21 +1,3 @@ -import { Geometry } from '../../common/interfaces'; +import { Feature } from 'geojson'; -/* eslint-disable @typescript-eslint/naming-convention */ -interface Properties { - OBJECTID: number; - OBJECT_COMMAND_NAME: string; - FIRST_GFID: null; // TODO: findout real type - SHAPE_Length: number; - F_CODE: number; - F_ATT: number; - LAYER_NAME: string; - ENTITY_HEB: string; - TYPE: string; -} - -export interface Route { - type: string; - id: number; - geometry: Geometry; - properties: Properties; -} +export interface Route extends Feature {} diff --git a/src/route/models/routeManager.ts b/src/route/models/routeManager.ts index a188bbfd..31a47127 100644 --- a/src/route/models/routeManager.ts +++ b/src/route/models/routeManager.ts @@ -6,6 +6,7 @@ import { SERVICES } from '../../common/constants'; import { ROUTE_REPOSITORY_SYMBOL, RouteRepository } from '../DAL/routeRepository'; import { RouteQueryParams } from '../DAL/queries'; import { formatResponse } from '../../common/utils'; +import { FeatureCollection } from '../../common/interfaces'; import { Route } from './route'; @injectable() @@ -16,14 +17,7 @@ export class RouteManager { @inject(ROUTE_REPOSITORY_SYMBOL) private readonly routeRepository: RouteRepository ) {} - public async getRoutes( - routeQueryParams: RouteQueryParams, - reduceFuzzyMatch = false, - size?: number - ): Promise<{ - type: string; - features: (Route | undefined)[]; - }> { + public async getRoutes(routeQueryParams: RouteQueryParams, reduceFuzzyMatch = false, size?: number): Promise> { let elasticResponse: estypes.SearchResponse | undefined = undefined; if (routeQueryParams.controlPoint ?? 0) { elasticResponse = await this.routeRepository.getControlPointInRoute( @@ -39,8 +33,8 @@ export class RouteManager { if (reduceFuzzyMatch && formattedResponse.features.length > 0) { const filterFunction = routeQueryParams.controlPoint ?? 0 - ? (hit: Route | undefined): hit is Route => hit?.properties.OBJECT_COMMAND_NAME === routeQueryParams.controlPoint - : (hit: Route | undefined): hit is Route => hit?.properties.OBJECT_COMMAND_NAME === routeQueryParams.commandName; + ? (hit: Route | undefined): hit is Route => hit?.properties?.OBJECT_COMMAND_NAME === routeQueryParams.controlPoint + : (hit: Route | undefined): hit is Route => hit?.properties?.OBJECT_COMMAND_NAME === routeQueryParams.commandName; formattedResponse.features = formattedResponse.features.filter(filterFunction); } diff --git a/src/tile/controllers/tileController.ts b/src/tile/controllers/tileController.ts index 786c5d45..27992fda 100644 --- a/src/tile/controllers/tileController.ts +++ b/src/tile/controllers/tileController.ts @@ -20,14 +20,16 @@ type GetResourceHandler = RequestHandler< message: string; }, undefined, - { - tile: string; - sub_tile?: string; - reduce_fuzzy_match?: string; - size?: string; - } + GetTilesQueryParams >; +export interface GetTilesQueryParams { + tile: string; + sub_tile?: string; + reduce_fuzzy_match?: string; + size?: string; +} + @injectable() export class TileController { private readonly createdResourceCounter: BoundCounter; diff --git a/src/tile/models/tile.ts b/src/tile/models/tile.ts index 2e73b796..91cc6452 100644 --- a/src/tile/models/tile.ts +++ b/src/tile/models/tile.ts @@ -1,19 +1,3 @@ -import { Geometry } from '../../common/interfaces'; +import { Feature } from 'geojson'; -/* eslint-disable @typescript-eslint/naming-convention */ -interface Properties { - OBJECTID: number; - ZONE: string; - TILE_NAME: string; - SUB_TILE_ID?: string; - TILE_ID: null | string; - LAYER_NAME: string; - TYPE: string; -} - -export interface Tile { - type: string; - id: number; - geometry: Geometry; - properties: Properties; -} +export interface Tile extends Feature {} diff --git a/src/tile/models/tileManager.ts b/src/tile/models/tileManager.ts index b07e9bed..881217dc 100644 --- a/src/tile/models/tileManager.ts +++ b/src/tile/models/tileManager.ts @@ -6,6 +6,7 @@ import { SERVICES } from '../../common/constants'; import { TILE_REPOSITORY_SYMBOL, TileRepository } from '../DAL/tileRepository'; import { formatResponse } from '../../common/utils'; import { TileQueryParams } from '../DAL/queries'; +import { FeatureCollection } from '../../common/interfaces'; import { Tile } from './tile'; @injectable() @@ -16,14 +17,7 @@ export class TileManager { @inject(TILE_REPOSITORY_SYMBOL) private readonly tileRepository: TileRepository ) {} - public async getTiles( - tileQueryParams: TileQueryParams, - reduceFuzzyMatch = false, - size?: number - ): Promise<{ - type: string; - features: (Tile | undefined)[]; - }> { + public async getTiles(tileQueryParams: TileQueryParams, reduceFuzzyMatch = false, size?: number): Promise> { let elasticResponse: estypes.SearchResponse | undefined = undefined; const numberOfResults = size ?? this.config.get('db.elastic.properties.size'); if (tileQueryParams.subTile ?? 0) { @@ -38,8 +32,8 @@ export class TileManager { const filterFunction = tileQueryParams.subTile ?? 0 ? (hit: Tile | undefined): hit is Tile => - hit?.properties.SUB_TILE_ID === tileQueryParams.subTile && hit?.properties.TILE_NAME === tileQueryParams.tile - : (hit: Tile | undefined): hit is Tile => hit?.properties.TILE_NAME === tileQueryParams.tile; + hit?.properties?.SUB_TILE_ID === tileQueryParams.subTile && hit?.properties?.TILE_NAME === tileQueryParams.tile + : (hit: Tile | undefined): hit is Tile => hit?.properties?.TILE_NAME === tileQueryParams.tile; formattedResponse.features = formattedResponse.features.filter(filterFunction); } diff --git a/tests/configurations/integration/jest.config.js b/tests/configurations/integration/jest.config.js index 59a7646e..3ae834e9 100644 --- a/tests/configurations/integration/jest.config.js +++ b/tests/configurations/integration/jest.config.js @@ -1,3 +1,4 @@ +/** @type {import('jest').Config} */ module.exports = { transform: { '^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.test.json' }], @@ -28,4 +29,5 @@ module.exports = { statements: -10, }, }, + testTimeout: 25000, }; diff --git a/tests/configurations/unit/jest.config.js b/tests/configurations/unit/jest.config.js index 68d6af7b..07af12e5 100644 --- a/tests/configurations/unit/jest.config.js +++ b/tests/configurations/unit/jest.config.js @@ -1,3 +1,4 @@ +/** @type {import('jest').Config} */ module.exports = { transform: { '^.+\\.ts$': ['ts-jest', { tsconfig: 'tsconfig.test.json' }], @@ -31,4 +32,5 @@ module.exports = { statements: -10, }, }, + testTimeout: 25000, }; diff --git a/tests/integration/anotherResource/anotherResourceName.spec.ts b/tests/integration/anotherResource/anotherResourceName.spec.ts deleted file mode 100644 index 29588869..00000000 --- a/tests/integration/anotherResource/anotherResourceName.spec.ts +++ /dev/null @@ -1,40 +0,0 @@ -import jsLogger from '@map-colonies/js-logger'; -import { trace } from '@opentelemetry/api'; -import httpStatusCodes from 'http-status-codes'; -import { getApp } from '../../../src/app'; -import { SERVICES } from '../../../src/common/constants'; -import { IAnotherResourceModel } from '../../../src/anotherResource/models/anotherResourceManager'; -import { AnotherResourceRequestSender } from './helpers/requestSender'; - -describe('resourceName', function () { - let requestSender: AnotherResourceRequestSender; - beforeEach(function () { - const app = getApp({ - override: [ - { token: SERVICES.LOGGER, provider: { useValue: jsLogger({ enabled: false }) } }, - { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, - ], - useChild: true, - }); - requestSender = new AnotherResourceRequestSender(app); - }); - - describe('Happy Path', function () { - it('should return 200 status code and the resource', async function () { - const response = await requestSender.getResource(); - - expect(response.status).toBe(httpStatusCodes.OK); - expect(response).toSatisfyApiSpec(); - - const resource = response.body as IAnotherResourceModel; - expect(resource.kind).toBe('avi'); - expect(resource.isAlive).toBe(false); - }); - }); - describe('Bad Path', function () { - // All requests with status code of 400 - }); - describe('Sad Path', function () { - // All requests with status code 4XX-5XX - }); -}); diff --git a/tests/integration/anotherResource/helpers/requestSender.ts b/tests/integration/anotherResource/helpers/requestSender.ts deleted file mode 100644 index e9a0bed1..00000000 --- a/tests/integration/anotherResource/helpers/requestSender.ts +++ /dev/null @@ -1,9 +0,0 @@ -import * as supertest from 'supertest'; - -export class AnotherResourceRequestSender { - public constructor(private readonly app: Express.Application) {} - - public async getResource(): Promise { - return supertest.agent(this.app).get('/anotherResource').set('Content-Type', 'application/json'); - } -} diff --git a/tests/integration/docs/docs.spec.ts b/tests/integration/docs/docs.spec.ts index eb4242f1..88f06840 100644 --- a/tests/integration/docs/docs.spec.ts +++ b/tests/integration/docs/docs.spec.ts @@ -7,15 +7,15 @@ import { DocsRequestSender } from './helpers/docsRequestSender'; describe('docs', function () { let requestSender: DocsRequestSender; - beforeEach(function () { - const app = getApp({ + beforeEach(async function () { + const app = await getApp({ override: [ { token: SERVICES.LOGGER, provider: { useValue: jsLogger({ enabled: false }) } }, { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, ], useChild: true, }); - requestSender = new DocsRequestSender(app); + requestSender = new DocsRequestSender(app.app); }); describe('Happy Path', function () { diff --git a/tests/integration/item/helpers/requestSender.ts b/tests/integration/item/helpers/requestSender.ts new file mode 100644 index 00000000..6b07c6fd --- /dev/null +++ b/tests/integration/item/helpers/requestSender.ts @@ -0,0 +1,15 @@ +import * as supertest from 'supertest'; +import { GetItemsQueryParams } from '../../../../src/item/controllers/itemController'; + +export class ItemRequestSender { + public constructor(private readonly app: Express.Application) {} + + public async getItems(queryParams?: GetItemsQueryParams): Promise { + return supertest + .agent(this.app) + .get('/search/items/') + .set('Content-Type', 'application/json') + .set('X-API-Key', 'abc123') + .query(queryParams ?? {}); + } +} diff --git a/tests/integration/item/item.spec.ts b/tests/integration/item/item.spec.ts new file mode 100644 index 00000000..d1eb396b --- /dev/null +++ b/tests/integration/item/item.spec.ts @@ -0,0 +1,67 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import jsLogger from '@map-colonies/js-logger'; +import { trace } from '@opentelemetry/api'; +import httpStatusCodes from 'http-status-codes'; +import { getApp } from '../../../src/app'; +import { SERVICES } from '../../../src/common/constants'; +import { GetItemsQueryParams } from '../../../src/item/controllers/itemController'; +import { ItemRequestSender } from './helpers/requestSender'; + +describe('/items', function () { + let requestSender: ItemRequestSender; + + beforeEach(async function () { + const app = await getApp({ + override: [ + { token: SERVICES.LOGGER, provider: { useValue: jsLogger({ enabled: false }) } }, + { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, + ], + useChild: true, + }); + requestSender = new ItemRequestSender(app.app); + }); + + describe('Happy Path', function () { + it('should return 200 status code and the item', async function () { + const response = await requestSender.getItems({ command_name: '4805' }); + + expect(response.status).toBe(httpStatusCodes.OK); + expect(response).toSatisfyApiSpec(); + }); + it('should return 200 status code and empty response', async function () { + const response = await requestSender.getItems({ command_name: '48054805' }); + + expect(response.status).toBe(httpStatusCodes.OK); + expect(response).toSatisfyApiSpec(); + expect(response.body).toMatchObject({ + type: 'FeatureCollection', + features: [], + }); + }); + }); + describe('Bad Path', function () { + // All requests with status code of 400 + it('Should return 400 status code and meessage "request/query must have required property \'command_name\'"', async function () { + const message = "request/query must have required property 'command_name'"; + + const response = await requestSender.getItems(); + + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.body).toMatchObject({ message }); + expect(response).toSatisfyApiSpec(); + }); + it('Should return 400 status code for unknown parameter', async function () { + const parameter = 'test1234'; + const message = `Unknown query parameter '${parameter}'`; + + const response = await requestSender.getItems({ [parameter]: parameter } as unknown as GetItemsQueryParams); + + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.body).toMatchObject({ message }); + expect(response).toSatisfyApiSpec(); + }); + }); + describe('Sad Path', function () { + // All requests with status code 4XX-5XX + }); +}); diff --git a/tests/integration/latLon/helpers/requestSender.ts b/tests/integration/latLon/helpers/requestSender.ts new file mode 100644 index 00000000..1cada784 --- /dev/null +++ b/tests/integration/latLon/helpers/requestSender.ts @@ -0,0 +1,49 @@ +import * as supertest from 'supertest'; +import { + GetLatLonToTileQueryParams, + GetTileToLatLonQueryParams, + GetLatLonToMgrsQueryParams, + GetMgrsToLatLonQueryParams, +} from '../../../../src/latLon/controllers/latLonController'; + +const PREFIX = '/lookup'; + +export class LatLonRequestSender { + public constructor(private readonly app: Express.Application) {} + + public async getLatlonToTile(queryParams?: GetLatLonToTileQueryParams): Promise { + return supertest + .agent(this.app) + .get(`${PREFIX}/latlonToTile`) + .set('Content-Type', 'application/json') + .set('X-API-Key', 'abc123') + .query(queryParams ?? {}); + } + + public async getTileToLatLon(queryParams?: GetTileToLatLonQueryParams): Promise { + return supertest + .agent(this.app) + .get(`${PREFIX}/tileToLatLon`) + .set('Content-Type', 'application/json') + .set('X-API-Key', 'abc123') + .query(queryParams ?? {}); + } + + public async getLatlonToMgrs(queryParams?: GetLatLonToMgrsQueryParams): Promise { + return supertest + .agent(this.app) + .get(`${PREFIX}/latlonToMgrs`) + .set('Content-Type', 'application/json') + .set('X-API-Key', 'abc123') + .query(queryParams ?? {}); + } + + public async getMgrsToLatlon(queryParams?: GetMgrsToLatLonQueryParams): Promise { + return supertest + .agent(this.app) + .get(`${PREFIX}/mgrsToLatLon`) + .set('Content-Type', 'application/json') + .set('X-API-Key', 'abc123') + .query(queryParams ?? {}); + } +} diff --git a/tests/integration/latLon/latLon.spec.ts b/tests/integration/latLon/latLon.spec.ts new file mode 100644 index 00000000..c656effb --- /dev/null +++ b/tests/integration/latLon/latLon.spec.ts @@ -0,0 +1,169 @@ +/* eslint-disable @typescript-eslint/unbound-method */ +/* eslint-disable @typescript-eslint/naming-convention */ +import jsLogger from '@map-colonies/js-logger'; +import { trace } from '@opentelemetry/api'; +import httpStatusCodes from 'http-status-codes'; +import { getApp } from '../../../src/app'; +import { SERVICES } from '../../../src/common/constants'; +import { LatLonRequestSender } from './helpers/requestSender'; + +describe('/latLon', function () { + let requestSender: LatLonRequestSender; + + beforeEach(async function () { + const app = await getApp({ + override: [ + { token: SERVICES.LOGGER, provider: { useValue: jsLogger({ enabled: false }) } }, + { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, + ], + useChild: true, + }); + requestSender = new LatLonRequestSender(app.app); + }, 20000); + + describe('Happy Path', function () { + it('should return 200 status code and lat-lon from mgrs', async function () { + const response = await requestSender.getMgrsToLatlon({ mgrs: '18TWL8565011369' }); + + expect(response.status).toBe(httpStatusCodes.OK); + expect(response).toSatisfyApiSpec(); + expect(response.body).toMatchObject({ + lat: 40.74882151233783, + lon: -73.98543192220956, + }); + }); + + it('should return 200 status code and mgrs from lat-lon', async function () { + const reponse = await requestSender.getLatlonToMgrs({ + lat: 40.74882151233783, + lon: -73.98543192220956, + }); + + expect(reponse.status).toBe(httpStatusCodes.OK); + expect(reponse).toSatisfyApiSpec(); + expect(reponse.body).toMatchObject({ + mgrs: '18TWL8565011369', + }); + }); + + it('should return 200 status code and tile from lat-lon', async function () { + const reponse = await requestSender.getLatlonToTile({ + lat: 52.57326537485767, + lon: 12.948781146422107, + }); + + expect(reponse.status).toBe(httpStatusCodes.OK); + expect(reponse).toSatisfyApiSpec(); + expect(reponse.body).toMatchObject({ + tileName: 'BRN', + subTileNumber: [0, 0, 0], + }); + }); + + it('should return 200 status code and lat-lon from tile', async function () { + const reponse = await requestSender.getTileToLatLon({ + tile: 'BRN', + sub_tile_number: [10, 10, 10], + }); + + expect(reponse.status).toBe(httpStatusCodes.OK); + expect(reponse).toSatisfyApiSpec(); + expect(reponse.body).toMatchObject({ + type: 'FeatureCollection', + features: [ + { + geometry: { + coordinates: [ + [ + [12.953293384350397, 52.512399536846765], + [12.953440643865289, 52.512402084451686], + [12.953436468347887, 52.51249192878939], + [12.95328920853307, 52.512489381176245], + [12.953293384350397, 52.512399536846765], + ], + ], + type: 'Polygon', + }, + properties: { + TYPE: 'TILE', + SUB_TILE_NUMBER: [10, 10, 10], + TILE_NAME: 'BRN', + }, + }, + ], + }); + }); + }); + describe('Bad Path', function () { + test.each<[keyof typeof requestSender, string[]]>([ + ['getLatlonToMgrs', ['lat', 'lon']], + ['getMgrsToLatlon', ['mgrs']], + ['getLatlonToTile', ['lat', 'lon']], + ['getTileToLatLon', ['tile', 'sub_tile_number']], + ])('should return 400 and message for missing required parameters', async (request, missingProperties) => { + const message = 'request/query must have required property'; + const response = await requestSender[request](); + + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.body).toMatchObject({ message: missingProperties.map((txt) => `${message} '${txt}'`).join(', ') }); + expect(response).toSatisfyApiSpec(); + }); + + test.each<[keyof typeof requestSender]>([['getLatlonToMgrs'], ['getMgrsToLatlon'], ['getLatlonToTile'], ['getTileToLatLon']])( + 'should return 400 and message unknown query parameter', + async (request) => { + const parameter = 'test1234'; + const message = `Unknown query parameter '${parameter}'`; + + const response = await requestSender[request]({ [parameter]: parameter } as never); + + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.body).toMatchObject({ message }); + expect(response).toSatisfyApiSpec(); + } + ); + + it('should return 400 and check that all numbers are positive', async function () { + const message = "Invalid tile, check that 'tileName' and 'subTileNumber' exists and subTileNumber is array of size 3 with positive integers"; + + for (let i = 0; i < 3; i++) { + const arr: number[] = [10, 10, 10]; + arr[i] = 0; + + const response = await requestSender.getTileToLatLon({ + tile: 'BRN', + sub_tile_number: arr, + }); + + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.body).toMatchObject({ + message, + }); + expect(response).toSatisfyApiSpec(); + } + }); + + it('should return 400 for lat-lon that outside the grid extent', async function () { + const response = await requestSender.getLatlonToTile({ lat: 1, lon: 1 }); + + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.body).toMatchObject({ + message: 'The coordinate is outside the grid extent', + }); + expect(response).toSatisfyApiSpec(); + }); + + it('should return 400 for tile not found', async function () { + const response = await requestSender.getTileToLatLon({ tile: 'XXX', sub_tile_number: [10, 10, 10] }); + + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.body).toMatchObject({ + message: 'Tile not found', + }); + expect(response).toSatisfyApiSpec(); + }); + }); + describe('Sad Path', function () { + // All requests with status code 4XX-5XX + }); +}); diff --git a/tests/integration/resourceName/helpers/requestSender.ts b/tests/integration/resourceName/helpers/requestSender.ts deleted file mode 100644 index 0befb952..00000000 --- a/tests/integration/resourceName/helpers/requestSender.ts +++ /dev/null @@ -1,13 +0,0 @@ -import * as supertest from 'supertest'; - -export class ResourceNameRequestSender { - public constructor(private readonly app: Express.Application) {} - - public async getResource(): Promise { - return supertest.agent(this.app).get('/resourceName').set('Content-Type', 'application/json'); - } - - public async createResource(): Promise { - return supertest.agent(this.app).post('/resourceName').set('Content-Type', 'application/json'); - } -} diff --git a/tests/integration/resourceName/resourceName.spec.ts b/tests/integration/resourceName/resourceName.spec.ts deleted file mode 100644 index 7edad920..00000000 --- a/tests/integration/resourceName/resourceName.spec.ts +++ /dev/null @@ -1,47 +0,0 @@ -import jsLogger from '@map-colonies/js-logger'; -import { trace } from '@opentelemetry/api'; -import httpStatusCodes from 'http-status-codes'; - -import { getApp } from '../../../src/app'; -import { SERVICES } from '../../../src/common/constants'; -import { IResourceNameModel } from '../../../src/resourceName/models/resourceNameManager'; -import { ResourceNameRequestSender } from './helpers/requestSender'; - -describe('resourceName', function () { - let requestSender: ResourceNameRequestSender; - beforeEach(function () { - const app = getApp({ - override: [ - { token: SERVICES.LOGGER, provider: { useValue: jsLogger({ enabled: false }) } }, - { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, - ], - useChild: true, - }); - requestSender = new ResourceNameRequestSender(app); - }); - - describe('Happy Path', function () { - it('should return 200 status code and the resource', async function () { - const response = await requestSender.getResource(); - - expect(response.status).toBe(httpStatusCodes.OK); - - const resource = response.body as IResourceNameModel; - expect(response).toSatisfyApiSpec(); - expect(resource.id).toBe(1); - expect(resource.name).toBe('ronin'); - expect(resource.description).toBe('can you do a logistics run?'); - }); - it('should return 200 status code and create the resource', async function () { - const response = await requestSender.createResource(); - - expect(response.status).toBe(httpStatusCodes.CREATED); - }); - }); - describe('Bad Path', function () { - // All requests with status code of 400 - }); - describe('Sad Path', function () { - // All requests with status code 4XX-5XX - }); -}); diff --git a/tests/integration/route/helpers/requestSender.ts b/tests/integration/route/helpers/requestSender.ts new file mode 100644 index 00000000..767155ee --- /dev/null +++ b/tests/integration/route/helpers/requestSender.ts @@ -0,0 +1,15 @@ +import * as supertest from 'supertest'; +import { GetRoutesQueryParams } from '../../../../src/route/controllers/routeController'; + +export class RouteRequestSender { + public constructor(private readonly app: Express.Application) {} + + public async getRoutes(queryParams?: GetRoutesQueryParams): Promise { + return supertest + .agent(this.app) + .get('/search/routes/') + .set('Content-Type', 'application/json') + .set('X-API-Key', 'abc123') + .query(queryParams ?? {}); + } +} diff --git a/tests/integration/route/route.spec.ts b/tests/integration/route/route.spec.ts new file mode 100644 index 00000000..6e195f95 --- /dev/null +++ b/tests/integration/route/route.spec.ts @@ -0,0 +1,68 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import jsLogger from '@map-colonies/js-logger'; +import { trace } from '@opentelemetry/api'; +import httpStatusCodes from 'http-status-codes'; +import { getApp } from '../../../src/app'; +import { SERVICES } from '../../../src/common/constants'; +import { GetRoutesQueryParams } from '../../../src/route/controllers/routeController'; +import { RouteRequestSender } from './helpers/requestSender'; + +describe('/routes', function () { + let requestSender: RouteRequestSender; + + beforeEach(async function () { + const app = await getApp({ + override: [ + { token: SERVICES.LOGGER, provider: { useValue: jsLogger({ enabled: false }) } }, + { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, + ], + useChild: true, + }); + requestSender = new RouteRequestSender(app.app); + }); + + describe('Happy Path', function () { + it('should return 200 status code and the route', async function () { + const response = await requestSender.getRoutes({ command_name: 'route96' }); + + expect(response.status).toBe(httpStatusCodes.OK); + expect(response).toSatisfyApiSpec(); + }); + it('should return 200 status code and empty response', async function () { + const response = await requestSender.getRoutes({ command_name: '48054805' }); + + expect(response.status).toBe(httpStatusCodes.OK); + + expect(response).toSatisfyApiSpec(); + expect(response.body).toMatchObject({ + type: 'FeatureCollection', + features: [], + }); + }); + }); + describe('Bad Path', function () { + // All requests with status code of 400 + it('Should return 400 status code and meessage "request/query must have required property \'command_name\'"', async function () { + const message = "request/query must have required property 'command_name'"; + + const response = await requestSender.getRoutes(); + + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.body).toMatchObject({ message }); + expect(response).toSatisfyApiSpec(); + }); + it('Should return 400 status code for unknown parameter', async function () { + const parameter = 'test1234'; + const message = `Unknown query parameter '${parameter}'`; + + const response = await requestSender.getRoutes({ [parameter]: parameter } as unknown as GetRoutesQueryParams); + + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.body).toMatchObject({ message }); + expect(response).toSatisfyApiSpec(); + }); + }); + describe('Sad Path', function () { + // All requests with status code 4XX-5XX + }); +}); diff --git a/tests/integration/tile/helpers/requestSender.ts b/tests/integration/tile/helpers/requestSender.ts new file mode 100644 index 00000000..2d8ec5b4 --- /dev/null +++ b/tests/integration/tile/helpers/requestSender.ts @@ -0,0 +1,15 @@ +import * as supertest from 'supertest'; +import { GetTilesQueryParams } from '../../../../src/tile/controllers/tileController'; + +export class TileRequestSender { + public constructor(private readonly app: Express.Application) {} + + public async getTiles(queryParams?: GetTilesQueryParams): Promise { + return supertest + .agent(this.app) + .get('/search/tiles/') + .set('Content-Type', 'application/json') + .set('X-API-Key', 'abc123') + .query(queryParams ?? {}); + } +} diff --git a/tests/integration/tile/tile.spec.ts b/tests/integration/tile/tile.spec.ts new file mode 100644 index 00000000..abd5bd6e --- /dev/null +++ b/tests/integration/tile/tile.spec.ts @@ -0,0 +1,124 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import jsLogger from '@map-colonies/js-logger'; +import { trace } from '@opentelemetry/api'; +import httpStatusCodes from 'http-status-codes'; +import { getApp } from '../../../src/app'; +import { SERVICES } from '../../../src/common/constants'; +import { GetTilesQueryParams } from '../../../src/tile/controllers/tileController'; +import { TileRequestSender } from './helpers/requestSender'; + +describe('/tiles', function () { + let requestSender: TileRequestSender; + + beforeEach(async function () { + const app = await getApp({ + override: [ + { token: SERVICES.LOGGER, provider: { useValue: jsLogger({ enabled: false }) } }, + { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, + ], + useChild: true, + }); + requestSender = new TileRequestSender(app.app); + }); + + describe('Happy Path', function () { + it('should return 200 status code and the tile', async function () { + const response = await requestSender.getTiles({ tile: 'RIT' }); + + expect(response.status).toBe(httpStatusCodes.OK); + expect(response).toSatisfyApiSpec(); + expect(response.body).toMatchObject({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + coordinates: [ + [ + [12.539507865186607, 41.851751203650096], + [12.536787075186538, 41.94185043165008], + [12.42879133518656, 41.93952837265009], + [12.431625055186686, 41.84943698365008], + [12.539507865186607, 41.851751203650096], + ], + ], + type: 'Polygon', + }, + properties: { + TILE_NAME: 'RIT', + TYPE: 'TILE', + }, + }, + ], + }); + }); + + it('should return 200 status code and the subtile', async function () { + const response = await requestSender.getTiles({ tile: 'GRC', sub_tile: '65' }); + + expect(response.status).toBe(httpStatusCodes.OK); + expect(response).toSatisfyApiSpec(); + expect(response.body).toMatchObject({ + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + coordinates: [ + [ + [27.149158174343427, 35.63159611670335], + [27.149274355343437, 35.64061707270338], + [27.138786228343463, 35.640716597703374], + [27.13867103934342, 35.631695606703374], + [27.149158174343427, 35.63159611670335], + ], + ], + type: 'Polygon', + }, + properties: { + SUB_TILE_ID: '65', + TILE_NAME: 'GRC', + TYPE: 'SUB_TILE', + }, + }, + ], + }); + }); + + it('should return 200 status code and response empty array', async function () { + const response = await requestSender.getTiles({ tile: 'xyz' }); + + expect(response.status).toBe(httpStatusCodes.OK); + expect(response).toSatisfyApiSpec(); + expect(response.body).toMatchObject({ + type: 'FeatureCollection', + features: [], + }); + }); + }); + describe('Bad Path', function () { + // All requests with status code of 400 + it('Should return 400 status code and meessage "request/query must have required property \'tile\'"', async function () { + const message = "request/query must have required property 'tile'"; + + const response = await requestSender.getTiles(); + + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.body).toMatchObject({ message }); + expect(response).toSatisfyApiSpec(); + }); + it('Should return 400 status code for unknown parameter', async function () { + const parameter = 'test1234'; + const message = `Unknown query parameter '${parameter}'`; + + const response = await requestSender.getTiles({ [parameter]: parameter } as unknown as GetTilesQueryParams); + + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.body).toMatchObject({ message }); + expect(response).toSatisfyApiSpec(); + }); + }); + describe('Sad Path', function () { + // All requests with status code 4XX-5XX + }); +}); diff --git a/tests/unit/anotherResource/models/anotherResourceManager.spec.ts b/tests/unit/anotherResource/models/anotherResourceManager.spec.ts deleted file mode 100644 index 846845e6..00000000 --- a/tests/unit/anotherResource/models/anotherResourceManager.spec.ts +++ /dev/null @@ -1,20 +0,0 @@ -import jsLogger from '@map-colonies/js-logger'; -import { AnotherResourceManager } from '../../../../src/anotherResource/models/anotherResourceManager'; - -let anotherResourceManager: AnotherResourceManager; - -describe('ResourceNameManager', () => { - beforeEach(function () { - anotherResourceManager = new AnotherResourceManager(jsLogger({ enabled: false })); - }); - describe('#getResource', () => { - it('should return resource of kind avi', function () { - // action - const resource = anotherResourceManager.getResource(); - - // expectation - expect(resource.kind).toBe('avi'); - expect(resource.isAlive).toBe(false); - }); - }); -}); diff --git a/tests/unit/common/objects.ts b/tests/unit/common/objects.ts new file mode 100644 index 00000000..3ef7ec5b --- /dev/null +++ b/tests/unit/common/objects.ts @@ -0,0 +1,271 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers */ +/* eslint-disable @typescript-eslint/naming-convention */ +import { estypes } from '@elastic/elasticsearch'; +import { FeatureCollection } from 'geojson'; + +/* ------------------- Objects for testing common/utils.ts: formatResponse() - ITEM type -------------------*/ +export const itemElasticResponse: Pick = { + hits: { + total: { value: 1, relation: 'eq' }, + max_score: 1.89712, + hits: [ + { + _index: 'control_gil_v5', + _id: 'CONTROL.ITEMS', + _score: 1.89712, + _source: { + type: 'Feature', + geometry: { + coordinates: [ + [ + [98.96871358832425, 18.77187541003238], + [98.96711613001014, 18.772012912146167], + [98.9668257091372, 18.77957500633211], + [98.96517988589727, 18.783516234000658], + [98.96305002071, 18.786174113473763], + [98.96222710028087, 18.786036643850125], + [98.9615009529004, 18.784478609414165], + [98.96096850521184, 18.78324114944766], + [98.96058105300438, 18.74932410944021], + [98.96029062202649, 18.747169677704846], + [98.9619364723165, 18.74666541646775], + [98.96479250629358, 18.7514784826093], + [98.96401797557468, 18.75193687161918], + [98.96614791571238, 18.754412116891245], + [98.96639000300826, 18.75940841151673], + [98.96779381973744, 18.759133392649602], + [98.96798745662056, 18.76018763174328], + [98.96789063809456, 18.761746062357858], + [98.96905242991687, 18.763487833908357], + [98.96871358832425, 18.77187541003238], + ], + ], + type: 'Polygon', + }, + properties: { + OBJECT_COMMAND_NAME: '4805', + TILE_NAME: 'DEF', + SUB_TILE_ID: '36', + ENTITY_HEB: 'airport', + TYPE: 'ITEM', + }, + }, + }, + ], + }, +}; + +export const itemExpectedFormattedResponse: FeatureCollection = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + coordinates: [ + [ + [98.96871358832425, 18.77187541003238], + [98.96711613001014, 18.772012912146167], + [98.9668257091372, 18.77957500633211], + [98.96517988589727, 18.783516234000658], + [98.96305002071, 18.786174113473763], + [98.96222710028087, 18.786036643850125], + [98.9615009529004, 18.784478609414165], + [98.96096850521184, 18.78324114944766], + [98.96058105300438, 18.74932410944021], + [98.96029062202649, 18.747169677704846], + [98.9619364723165, 18.74666541646775], + [98.96479250629358, 18.7514784826093], + [98.96401797557468, 18.75193687161918], + [98.96614791571238, 18.754412116891245], + [98.96639000300826, 18.75940841151673], + [98.96779381973744, 18.759133392649602], + [98.96798745662056, 18.76018763174328], + [98.96789063809456, 18.761746062357858], + [98.96905242991687, 18.763487833908357], + [98.96871358832425, 18.77187541003238], + ], + ], + type: 'Polygon', + }, + properties: { + OBJECT_COMMAND_NAME: '4805', + TILE_NAME: 'DEF', + SUB_TILE_ID: '36', + ENTITY_HEB: 'airport', + TYPE: 'ITEM', + }, + }, + ], +}; + +/* ------------------- Objects for testing common/utils.ts: formatResponse() - TILE type -------------------*/ + +export const tileElasticResponse: Pick = { + hits: { + total: { value: 1, relation: 'eq' }, + max_score: 2.184802, + hits: [ + { + _index: 'control_gil_v5', + _id: 'CONTROL.TILES', + _score: 2.184802, + _source: { + type: 'Feature', + geometry: { + coordinates: [ + [ + [12.539507865186607, 41.851751203650096], + [12.536787075186538, 41.94185043165008], + [12.42879133518656, 41.93952837265009], + [12.431625055186686, 41.84943698365008], + [12.539507865186607, 41.851751203650096], + ], + ], + type: 'Polygon', + }, + properties: { TILE_NAME: 'RIT', TYPE: 'TILE' }, + }, + }, + ], + }, +}; + +export const tileExpectedFormattedResponse: FeatureCollection = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + coordinates: [ + [ + [12.539507865186607, 41.851751203650096], + [12.536787075186538, 41.94185043165008], + [12.42879133518656, 41.93952837265009], + [12.431625055186686, 41.84943698365008], + [12.539507865186607, 41.851751203650096], + ], + ], + type: 'Polygon', + }, + properties: { TILE_NAME: 'RIT', TYPE: 'TILE' }, + }, + ], +}; + +/* ------------------- Objects for testing common/utils.ts: formatResponse() - SUB_TILE type -------------------*/ + +export const subTileElasticResponse: Pick = { + hits: { + total: { value: 1, relation: 'eq' }, + max_score: 2.877949, + hits: [ + { + _index: 'control_gil_v5', + _id: 'CONTROL.SUB_TILES', + _score: 2.877949, + _source: { + type: 'Feature', + geometry: { + coordinates: [ + [ + [27.149158174343427, 35.63159611670335], + [27.149274355343437, 35.64061707270338], + [27.138786228343463, 35.640716597703374], + [27.13867103934342, 35.631695606703374], + [27.149158174343427, 35.63159611670335], + ], + ], + type: 'Polygon', + }, + properties: { SUB_TILE_ID: '65', TILE_NAME: 'GRC', TYPE: 'SUB_TILE' }, + }, + }, + ], + }, +}; +export const subTileExpectedFormattedResponse: FeatureCollection = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + coordinates: [ + [ + [27.149158174343427, 35.63159611670335], + [27.149274355343437, 35.64061707270338], + [27.138786228343463, 35.640716597703374], + [27.13867103934342, 35.631695606703374], + [27.149158174343427, 35.63159611670335], + ], + ], + type: 'Polygon', + }, + properties: { SUB_TILE_ID: '65', TILE_NAME: 'GRC', TYPE: 'SUB_TILE' }, + }, + ], +}; + +/* ------------------- Objects for testing common/utils.ts: formatResponse() - ROUTE type -------------------*/ + +export const routeElasticResponse: Pick = { + hits: { + total: { value: 1, relation: 'eq' }, + max_score: 1.89712, + hits: [ + { + _index: 'control_gil_v5', + _id: 'CONTROL.ROUTES', + _score: 1.89712, + _source: { + type: 'Feature', + geometry: { + coordinates: [ + [13.448493352142947, 52.31016611400918], + [13.447219581381603, 52.313370282889224], + [13.448088381125075, 52.31631514453963], + [13.450458681234068, 52.31867376333767], + [13.451112278530388, 52.32227665244022], + [13.449728938644029, 52.32463678850752], + [13.445021899434977, 52.32863442881066], + [13.444723882330948, 52.340023400115086], + [13.446229682887974, 52.34532799609971], + ], + type: 'LineString', + }, + properties: { + OBJECT_COMMAND_NAME: 'route96', + ENTITY_HEB: 'route96', + TYPE: 'ROUTE', + }, + }, + }, + ], + }, +}; +export const routeExpectedFormattedResponse: FeatureCollection = { + type: 'FeatureCollection', + features: [ + { + type: 'Feature', + geometry: { + coordinates: [ + [13.448493352142947, 52.31016611400918], + [13.447219581381603, 52.313370282889224], + [13.448088381125075, 52.31631514453963], + [13.450458681234068, 52.31867376333767], + [13.451112278530388, 52.32227665244022], + [13.449728938644029, 52.32463678850752], + [13.445021899434977, 52.32863442881066], + [13.444723882330948, 52.340023400115086], + [13.446229682887974, 52.34532799609971], + ], + type: 'LineString', + }, + properties: { + OBJECT_COMMAND_NAME: 'route96', + ENTITY_HEB: 'route96', + TYPE: 'ROUTE', + }, + }, + ], +}; diff --git a/tests/unit/common/utils.spec.ts b/tests/unit/common/utils.spec.ts new file mode 100644 index 00000000..62076f0b --- /dev/null +++ b/tests/unit/common/utils.spec.ts @@ -0,0 +1,72 @@ +/* eslint-disable @typescript-eslint/naming-convention */ +import { estypes } from '@elastic/elasticsearch'; +import { + formatResponse, + additionalSearchProperties, + convertUTMToWgs84, + convertWgs84ToUTM, + validateTile, + validateWGS84Coordinate, +} from '../../../src/common/utils'; +import config from '../../../config/test.json'; +import { FIELDS } from '../../../src/common/constants'; +import { + itemElasticResponse, + itemExpectedFormattedResponse, + tileElasticResponse, + tileExpectedFormattedResponse, + subTileElasticResponse, + subTileExpectedFormattedResponse, + routeElasticResponse, + routeExpectedFormattedResponse, +} from './objects'; + +describe('utils', () => { + test.each([ + [itemElasticResponse, itemExpectedFormattedResponse], + [tileElasticResponse, tileExpectedFormattedResponse], + [subTileElasticResponse, subTileExpectedFormattedResponse], + [routeElasticResponse, routeExpectedFormattedResponse], + ])('should convert ElasticSearch query response to FeatureCollection', (elasticResponse, formattedResponse) => { + const result = formatResponse(elasticResponse as estypes.SearchResponse); + expect(result).toMatchObject(formattedResponse); + }); + + it('should return additional search properties', () => { + const size = 10; + const result = additionalSearchProperties(size); + expect(result).toMatchObject({ size, index: config.db.elastic.properties.controlIndex, _source: FIELDS }); + }); + + it('should convert UTM to WGS84', () => { + const result = convertUTMToWgs84(630048, 4330433, 29); + expect(result).toMatchObject({ lat: 39.11335578352079, lng: -7.495780486809503 }); + }); + + it('should convert WGS84 to UTM', () => { + const result = convertWgs84ToUTM(39.11335712352982, -7.495784527093747); + expect(result).toMatchObject({ Easting: 630048, Northing: 4330433, ZoneNumber: 29 }); + }); + + test.each([ + [{ tileName: 'BRN', subTileNumber: [10, 10, 10] }, true], + [{ tileName: 'BRN', subTileNumber: [0, 10, 10] }, false], + [{ tileName: 'BRN', subTileNumber: [10, 0, 10] }, false], + [{ tileName: 'BRN', subTileNumber: [10, 10, 0] }, false], + [{ tileName: 'BRN', subTileNumber: [10, 10, 100] }, false], + [{ tileName: 'BRN', subTileNumber: [10, 10] }, false], + [{ tileName: '', subTileNumber: [10, 10, 10] }, false], + ])(`should validate tile`, (tile, expected) => { + expect(validateTile(tile as never)).toBe(expected); + }); + + test.each([ + [{ lon: 50, lat: 50 }, true], + [{ lon: 190, lat: 50 }, false], + [{ lon: 50, lat: 190 }, false], + [{ lon: -10, lat: 50 }, false], + [{ lon: 50, lat: -10 }, false], + ])('should validate WGS84 coordinate', (coordinate, expected) => { + expect(validateWGS84Coordinate(coordinate as never)).toBe(expected); + }); +}); diff --git a/tests/unit/resourceName/models/resourceNameModel.spec.ts b/tests/unit/resourceName/models/resourceNameModel.spec.ts index a23d2e78..323e639d 100644 --- a/tests/unit/resourceName/models/resourceNameModel.spec.ts +++ b/tests/unit/resourceName/models/resourceNameModel.spec.ts @@ -1,33 +1,40 @@ -import jsLogger from '@map-colonies/js-logger'; -import { ResourceNameManager } from '../../../../src/resourceName/models/resourceNameManager'; +// import jsLogger from '@map-colonies/js-logger'; +// import { ResourceNameManager } from '../../../../src/resourceName/models/resourceNameManager'; -let resourceNameManager: ResourceNameManager; +// let resourceNameManager: ResourceNameManager; -describe('ResourceNameManager', () => { - beforeEach(function () { - resourceNameManager = new ResourceNameManager(jsLogger({ enabled: false })); - }); - describe('#getResource', () => { - it('return the resource of id 1', function () { - // action - const resource = resourceNameManager.getResource(); +// describe('ResourceNameManager', () => { +// beforeEach(function () { +// resourceNameManager = new ResourceNameManager(jsLogger({ enabled: false })); +// }); +// describe('#getResource', () => { +// it('return the resource of id 1', function () { +// // action +// const resource = resourceNameManager.getResource(); - // expectation - expect(resource.id).toBe(1); - expect(resource.name).toBe('ronin'); - expect(resource.description).toBe('can you do a logistics run?'); - }); - }); - describe('#createResource', () => { - it('return the resource of id 1', function () { - // action - const resource = resourceNameManager.createResource({ description: 'meow', name: 'cat' }); +// // expectation +// expect(resource.id).toBe(1); +// expect(resource.name).toBe('ronin'); +// expect(resource.description).toBe('can you do a logistics run?'); +// }); +// }); +// describe('#createResource', () => { +// it('return the resource of id 1', function () { +// // action +// const resource = resourceNameManager.createResource({ description: 'meow', name: 'cat' }); + +// // expectation +// expect(resource.id).toBeLessThanOrEqual(100); +// expect(resource.id).toBeGreaterThanOrEqual(0); +// expect(resource).toHaveProperty('name', 'cat'); +// expect(resource).toHaveProperty('description', 'meow'); +// }); +// }); +// }); - // expectation - expect(resource.id).toBeLessThanOrEqual(100); - expect(resource.id).toBeGreaterThanOrEqual(0); - expect(resource).toHaveProperty('name', 'cat'); - expect(resource).toHaveProperty('description', 'meow'); - }); +describe('need to delete', () => { + it('should pass', () => { + const a = true; + expect(a).toBe(true); }); }); diff --git a/tsconfig.json b/tsconfig.json index f5df9461..59326bc4 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -9,7 +9,8 @@ "allowSyntheticDefaultImports": true, "moduleResolution": "node", "experimentalDecorators": true, - "emitDecoratorMetadata": true + "emitDecoratorMetadata": true, + "resolveJsonModule": true, }, "include": ["src"], "exclude": ["node_modules", "dist", "tests"]