diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json new file mode 100644 index 0000000..c956cd1 --- /dev/null +++ b/config/custom-environment-variables.json @@ -0,0 +1,9 @@ +{ + "externalServices": { + "catalog": "CATALOG_URL", + "lookupTables": { + "url": "LOOKUP_TABLES_URL", + "subUrl": "LOOKUP_TABLES_SUB_URL" + } + } +} diff --git a/config/default.json b/config/default.json index 02f9345..0f5589d 100644 --- a/config/default.json +++ b/config/default.json @@ -32,5 +32,12 @@ "options": null } } + }, + "externalServices": { + "catalog": "http://127.0.0.1:8080", + "lookupTables": { + "url": "http://127.0.0.1:8080", + "subUrl": "lookup-tables/lookupData" + } } } diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index 0cd9c61..2635724 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -30,6 +30,13 @@ data: {{- end }} {{- end }} npm_config_cache: /tmp/ + {{- with .Values.externalServices }} + CATALOG_URL: {{ .catalog | quote }} + {{- with .lookupTables }} + LOOKUP_TABLES_URL: {{ .url | quote }} + LOOKUP_TABLES_SUB_URL: {{ .subUrl | quote }} + {{- end }} + {{- end }} {{- with .Values.configManagement }} CONFIG_NAME: {{ .name | quote }} CONFIG_VERSION: {{ .version | quote }} diff --git a/helm/values.yaml b/helm/values.yaml index 46736ea..a6b7973 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -78,6 +78,12 @@ server: responseCompressionEnabled: true requestPayloadLimit: 1mb +externalServices: + catalog: '' + lookupTables: + url: '' + subUrl: '' + telemetry: logger: level: info diff --git a/package-lock.json b/package-lock.json index 0a0f98a..def67c3 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "@map-colonies/tracing": "^1.0.0", "@map-colonies/tracing-utils": "^2.0.0", "@opentelemetry/api": "^1.9.1", + "axios": "^1.20.0", "compression": "^1.8.0", "express": "^4.21.2", "express-openapi-validator": "^5.6.2", @@ -5599,7 +5600,6 @@ "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==", - "dev": true, "license": "MIT" }, "node_modules/at-least-node": { @@ -5622,13 +5622,40 @@ } }, "node_modules/axios": { - "version": "0.21.4", - "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", - "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", - "dev": true, + "version": "1.20.0", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.20.0.tgz", + "integrity": "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg==", "license": "MIT", "dependencies": { - "follow-redirects": "^1.14.0" + "follow-redirects": "^1.16.0", + "form-data": "^4.0.6", + "https-proxy-agent": "^5.0.1", + "proxy-from-env": "^2.1.0" + } + }, + "node_modules/axios/node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, + "node_modules/axios/node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" } }, "node_modules/balanced-match": { @@ -6103,7 +6130,6 @@ "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", "integrity": "sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==", - "dev": true, "license": "MIT", "dependencies": { "delayed-stream": "~1.0.0" @@ -6596,7 +6622,6 @@ "version": "1.0.0", "resolved": "https://registry.npmjs.org/delayed-stream/-/delayed-stream-1.0.0.tgz", "integrity": "sha512-ZySD7Nf91aLB0RxL4KGrKHBXl7Eds1DAmEdcoVawXnLD7SDhpNgtuII2aAkg7a7QS41jxPSZ17p4VdGnMHk3MQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=0.4.0" @@ -6777,7 +6802,6 @@ "version": "2.1.0", "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.1.0.tgz", "integrity": "sha512-j6vWzfrGVfyXxge+O0x5sh6cvxAog0a/4Rdd2K36zCMV5eJ+/+tOAngRO8cODMNWbVRdVlmGZQL2YS3yR8bIUA==", - "dev": true, "license": "MIT", "dependencies": { "es-errors": "^1.3.0", @@ -7595,7 +7619,6 @@ "version": "1.16.0", "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.16.0.tgz", "integrity": "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw==", - "dev": true, "funding": [ { "type": "individual", @@ -7616,7 +7639,6 @@ "version": "4.0.6", "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.6.tgz", "integrity": "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ==", - "dev": true, "license": "MIT", "dependencies": { "asynckit": "^0.4.0", @@ -7977,7 +7999,6 @@ "version": "1.0.2", "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", - "dev": true, "license": "MIT", "dependencies": { "has-symbols": "^1.0.3" @@ -9792,6 +9813,16 @@ "@types/node": "*" } }, + "node_modules/openapi-validator/node_modules/axios": { + "version": "0.21.4", + "resolved": "https://registry.npmjs.org/axios/-/axios-0.21.4.tgz", + "integrity": "sha512-ut5vewkiu8jjGBdqpM44XxjuCjq9LAKeHVmoVfHVzy8eHgxxq8SbAVQNovDA8mVi05kP0Ea/n/UzcSHcTJQfNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "follow-redirects": "^1.14.0" + } + }, "node_modules/opossum": { "version": "9.0.0", "resolved": "https://registry.npmjs.org/opossum/-/opossum-9.0.0.tgz", @@ -10649,6 +10680,15 @@ "node": ">= 0.10" } }, + "node_modules/proxy-from-env": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/proxy-from-env/-/proxy-from-env-2.1.0.tgz", + "integrity": "sha512-cJ+oHTW1VAEa8cJslgmUZrc+sjRKgAKl3Zyse6+PV38hZe/V6Z14TbCuXcan9F9ghlz4QrFr2c92TNF82UkYHA==", + "license": "MIT", + "engines": { + "node": ">=10" + } + }, "node_modules/pump": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", diff --git a/package.json b/package.json index 5791b80..6fb2382 100644 --- a/package.json +++ b/package.json @@ -37,9 +37,9 @@ "@godaddy/terminus": "^4.12.1", "@map-colonies/3d-shared": "file:../../3d-general/3d-shared", "@map-colonies/config": "^4.0.1", - "@map-colonies/jobnik-sdk": "^1.0.1", "@map-colonies/error-express-handler": "^4.0.0", "@map-colonies/express-access-log-middleware": "^5.0.0", + "@map-colonies/jobnik-sdk": "^1.0.1", "@map-colonies/js-logger": "^5.0.0", "@map-colonies/openapi-express-viewer": "^5.0.0", "@map-colonies/prometheus": "^1.0.0", @@ -48,6 +48,7 @@ "@map-colonies/tracing": "^1.0.0", "@map-colonies/tracing-utils": "^2.0.0", "@opentelemetry/api": "^1.9.1", + "axios": "^1.20.0", "compression": "^1.8.0", "express": "^4.21.2", "express-openapi-validator": "^5.6.2", diff --git a/src/common/appError.ts b/src/common/appError.ts new file mode 100644 index 0000000..26b6212 --- /dev/null +++ b/src/common/appError.ts @@ -0,0 +1,20 @@ +import type { StatusCodes } from 'http-status-codes'; + +export class AppError extends Error { + public override readonly name: string; + public readonly status: StatusCodes; + public readonly isOperational: boolean; + + public constructor(name: string, status: StatusCodes, description: string, isOperational: boolean) { + super(description); + + Object.setPrototypeOf(this, new.target.prototype); + + this.name = name; + this.status = status; + this.isOperational = isOperational; + this.message = description; + + Error.captureStackTrace(this); + } +} diff --git a/src/common/config.ts b/src/common/config.ts index 3541a48..47a6218 100644 --- a/src/common/config.ts +++ b/src/common/config.ts @@ -1,21 +1,56 @@ import { type ConfigInstance, config } from '@map-colonies/config'; import { commonBoilerplateV3, type commonBoilerplateV3Type } from '@map-colonies/schemas'; -// Choose here the type of the config instance and import this type from the entire application -type ConfigType = ConfigInstance; +interface LookupTablesConfig { + url: string; + subUrl: string; +} + +interface ExternalServicesConfig { + lookupTables: LookupTablesConfig; + catalog: string; +} + +type OpsTriggerConfigType = commonBoilerplateV3Type & { externalServices: ExternalServicesConfig }; + +type ConfigType = ConfigInstance; + +const opsTriggerConfigSchema = { + $id: 'https://mapcolonies.com/3d/opsTrigger/v1', + type: 'object', + allOf: [ + { $ref: commonBoilerplateV3.$id }, + { + type: 'object', + required: ['externalServices'], + properties: { + externalServices: { + type: 'object', + required: ['lookupTables', 'catalog'], + properties: { + lookupTables: { + type: 'object', + required: ['url', 'subUrl'], + properties: { + url: { type: 'string' }, + subUrl: { type: 'string' }, + }, + }, + catalog: { type: 'string' }, + }, + }, + }, + }, + ], +}; let configInstance: ConfigType | undefined; -/** - * Initializes the configuration by fetching it from the server. - * This should only be called from the instrumentation file. - * @returns A Promise that resolves when the configuration is successfully initialized. - */ async function initConfig(offlineMode?: boolean): Promise { - configInstance = await config({ - schema: commonBoilerplateV3, + configInstance = (await config({ + schema: opsTriggerConfigSchema as unknown as typeof commonBoilerplateV3, offlineMode, - }); + })) as unknown as ConfigType; } function getConfig(): ConfigType { @@ -26,4 +61,4 @@ function getConfig(): ConfigType { } export { getConfig, initConfig }; -export type { ConfigType }; +export type { ConfigType, LookupTablesConfig, ExternalServicesConfig }; diff --git a/src/externalServices/catalog/catalogCall.ts b/src/externalServices/catalog/catalogCall.ts new file mode 100644 index 0000000..3f0fd9e --- /dev/null +++ b/src/externalServices/catalog/catalogCall.ts @@ -0,0 +1,53 @@ +import axios from 'axios'; +import { inject, injectable } from 'tsyringe'; +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { SERVICES } from '@common/constants'; +import { AppError } from '@common/appError'; +import type { ConfigType } from '@common/config'; +import type { LogContext } from '@common/interfaces'; +import type { IFindRecordsPayload, Record3D } from './interfaces'; + +@injectable() +export class CatalogCall { + private readonly logContext: LogContext; + private readonly catalog: string; + + public constructor( + @inject(SERVICES.CONFIG) private readonly config: ConfigType, + @inject(SERVICES.LOGGER) private readonly logger: Logger + ) { + this.catalog = this.config.get('externalServices.catalog'); + this.logContext = { + fileName: __filename, + class: CatalogCall.name, + }; + } + + public async findRecords(payload: IFindRecordsPayload): Promise { + const logContext = { ...this.logContext, function: this.findRecords.name }; + this.logger.debug({ msg: `Searching in catalog ${this.catalog}/metadata/find`, logContext, payload }); + try { + const response = await axios.post(`${this.catalog}/metadata/find`, payload); + if (response.status !== StatusCodes.OK.valueOf()) { + this.logger.error({ msg: `Catalog returned unexpected status: ${response.status}`, logContext }); + throw new AppError('catalog', StatusCodes.INTERNAL_SERVER_ERROR, 'Problem with catalog during record lookup', true); + } + + const records = response.data; + if (!Array.isArray(records)) { + return []; + } + + this.logger.debug({ msg: `Found ${records.length} record(s) in catalog`, logContext }); + return records; + } catch (err) { + if (err instanceof AppError) { + throw err; + } + + this.logger.error({ msg: 'Something went wrong in catalog when trying to find records', logContext, err }); + throw new AppError('catalog', StatusCodes.INTERNAL_SERVER_ERROR, 'Problem with catalog find', true); + } + } +} diff --git a/src/externalServices/catalog/interfaces.ts b/src/externalServices/catalog/interfaces.ts new file mode 100644 index 0000000..7aa1b75 --- /dev/null +++ b/src/externalServices/catalog/interfaces.ts @@ -0,0 +1,17 @@ +export interface Record3D { + id: string; + productId?: string; + productName?: string; + productVersion?: number; + productStatus?: string; + links?: string; +} + +export interface IFindRecordsPayload { + id?: string; + productId?: string; + productName?: string; + productType?: string; + classification?: string; + productStatus?: string; +} diff --git a/src/externalServices/lookupTables/interfaces.ts b/src/externalServices/lookupTables/interfaces.ts new file mode 100644 index 0000000..99c30e4 --- /dev/null +++ b/src/externalServices/lookupTables/interfaces.ts @@ -0,0 +1,5 @@ +export interface ILookupOption { + value: string; + translationCode: string; + properties?: Record; +} diff --git a/src/externalServices/lookupTables/lookupTablesCall.ts b/src/externalServices/lookupTables/lookupTablesCall.ts new file mode 100644 index 0000000..7125e3a --- /dev/null +++ b/src/externalServices/lookupTables/lookupTablesCall.ts @@ -0,0 +1,41 @@ +import axios from 'axios'; +import { inject, injectable } from 'tsyringe'; +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { SERVICES } from '@common/constants'; +import { AppError } from '@common/appError'; +import type { ConfigType, LookupTablesConfig } from '@common/config'; +import type { LogContext } from '@common/interfaces'; +import type { ILookupOption } from './interfaces'; + +@injectable() +export class LookupTablesCall { + private readonly logContext: LogContext; + private readonly lookupTables: LookupTablesConfig; + + public constructor( + @inject(SERVICES.CONFIG) private readonly config: ConfigType, + @inject(SERVICES.LOGGER) private readonly logger: Logger + ) { + this.lookupTables = this.config.get('externalServices.lookupTables'); + this.logContext = { + fileName: __filename, + class: LookupTablesCall.name, + }; + } + + public async getClassifications(): Promise { + const logContext = { ...this.logContext, function: this.getClassifications.name }; + this.logger.debug({ msg: 'Get Classifications from lookup-tables service', logContext }); + try { + const response = await axios.get(`${this.lookupTables.url}/${this.lookupTables.subUrl}/classification`); + const classifications = response.data.map((item) => item.value); + this.logger.debug({ msg: 'Got Classifications', logContext, classifications }); + + return classifications; + } catch (err) { + this.logger.error({ msg: 'something went wrong with lookup-tables service', logContext, err }); + throw new AppError('lookup-tables', StatusCodes.INTERNAL_SERVER_ERROR, 'there is a problem with lookup-tables', true); + } + } +} diff --git a/src/record/controllers/recordController.ts b/src/record/controllers/recordController.ts index 65a4bce..84fed2a 100644 --- a/src/record/controllers/recordController.ts +++ b/src/record/controllers/recordController.ts @@ -28,10 +28,10 @@ export class RecordController { }); } - public createRecord: TypedRequestHandlers['createRecord'] = (req, res, next) => { + public createRecord: TypedRequestHandlers['createRecord'] = async (req, res, next) => { const logContext = { ...this.logContext, function: this.createRecord.name }; try { - const job = this.manager.createIngestion(req.body); + const job = await this.manager.createIngestion(req.body); this.ingestionJobCounter.inc(1); return res.status(StatusCodes.CREATED).json(job); } catch (err) { diff --git a/src/record/models/recordManager.ts b/src/record/models/recordManager.ts index a152175..7f907ae 100644 --- a/src/record/models/recordManager.ts +++ b/src/record/models/recordManager.ts @@ -3,6 +3,7 @@ import { inject, injectable } from 'tsyringe'; import type { components } from '@openapi'; import { SERVICES } from '@common/constants'; import type { LogContext } from '@common/interfaces'; +import { ValidationManager } from '../../validator/validationManager'; export type IngestionPayload = components['schemas']['ingestionPayload']; export type UpdatePayload = components['schemas']['updatePayload']; @@ -14,16 +15,20 @@ export type AckResponse = components['schemas']['ackResponse']; export class RecordManager { private readonly logContext: LogContext; - public constructor(@inject(SERVICES.LOGGER) private readonly logger: Logger) { + public constructor( + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(ValidationManager) private readonly validator: ValidationManager + ) { this.logContext = { fileName: __filename, class: RecordManager.name, }; } - public createIngestion(payload: IngestionPayload): JobResponse { + public async createIngestion(payload: IngestionPayload): Promise { const logContext = { ...this.logContext, function: this.createIngestion.name }; this.logger.info({ msg: 'creating ingestion job', logContext, modelPath: payload.modelPath, tilesetFilename: payload.tilesetFilename }); + await this.validator.validateIngestion(payload.metadata); return { jobId: 'stub-ingestion-job-id', status: 'PENDING' }; } diff --git a/src/validator/validationManager.ts b/src/validator/validationManager.ts new file mode 100644 index 0000000..bd97e2d --- /dev/null +++ b/src/validator/validationManager.ts @@ -0,0 +1,93 @@ +import type { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { inject, injectable } from 'tsyringe'; +import { new3DLayerMetadataSchema, geometrySchema } from '@map-colonies/3d-shared'; +import { SERVICES } from '@common/constants'; +import type { LogContext } from '@common/interfaces'; +import { AppError } from '@common/appError'; +import { LookupTablesCall } from '../externalServices/lookupTables/lookupTablesCall'; +import { CatalogCall } from '../externalServices/catalog/catalogCall'; + +export const ERROR_METADATA_DATE = 'imagingTimeBeginUTC must not be later than imagingTimeEndUTC'; +export const ERROR_METADATA_MISSING_DATE = 'imagingTimeBeginUTC and imagingTimeEndUTC are required'; +export const ERROR_METADATA_INVALID_DATE = 'imagingTimeBeginUTC and imagingTimeEndUTC must be valid dates'; +export const ERROR_METADATA_FOOTPRINT = 'Invalid footprint! Must be a GeoJSON Polygon or MultiPolygon with all-2D or all-3D coordinates'; +export const ERROR_METADATA_PRODUCT_NAME_UNIQUE = 'product name is not unique!'; + +@injectable() +export class ValidationManager { + private readonly logContext: LogContext; + + public constructor( + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(LookupTablesCall) private readonly lookupTables: LookupTablesCall, + @inject(CatalogCall) private readonly catalog: CatalogCall + ) { + this.logContext = { + fileName: __filename, + class: ValidationManager.name, + }; + } + + public async validateIngestion(metadata: Record): Promise { + const logContext = { ...this.logContext, function: this.validateIngestion.name }; + this.logger.info({ msg: 'ingestion validation start', logContext }); + + const parsed = new3DLayerMetadataSchema.safeParse(metadata); + if (!parsed.success) { + const message = parsed.error.issues.map((issue) => `${issue.path.join('.')}: ${issue.message}`).join('; '); + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, message, true); + } + + const footprintResult = geometrySchema.safeParse(metadata.footprint); + if (!footprintResult.success) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_FOOTPRINT, true); + } + + this.validateDates(metadata.imagingTimeBeginUTC, metadata.imagingTimeEndUTC); + + await this.validateClassification(parsed.data.classification); + await this.validateProductNameUnique(parsed.data.productName); + } + + private async validateProductNameUnique(productName: string): Promise { + const logContext = { ...this.logContext, function: this.validateProductNameUnique.name }; + const records = await this.catalog.findRecords({ productName }); + this.logger.debug({ msg: 'product name uniqueness validation', logContext, productName, matches: records.length }); + + if (records.length > 0) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_PRODUCT_NAME_UNIQUE, true); + } + } + + private validateDates(start: unknown, end: unknown): void { + if (start === undefined || end === undefined) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_MISSING_DATE, true); + } + + const startTime = new Date(start as string).getTime(); + const endTime = new Date(end as string).getTime(); + if (Number.isNaN(startTime) || Number.isNaN(endTime)) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_INVALID_DATE, true); + } + + if (startTime > endTime) { + throw new AppError('badRequest', StatusCodes.BAD_REQUEST, ERROR_METADATA_DATE, true); + } + } + + private async validateClassification(classification: string): Promise { + const logContext = { ...this.logContext, function: this.validateClassification.name }; + const classifications = await this.lookupTables.getClassifications(); + this.logger.debug({ msg: 'classification validation', logContext, classifications }); + + if (!classifications.includes(classification)) { + throw new AppError( + 'badRequest', + StatusCodes.BAD_REQUEST, + `classification is not a valid value. Optional values: ${classifications.join()}`, + true + ); + } + } +} diff --git a/tests/helpers/metadata.ts b/tests/helpers/metadata.ts new file mode 100644 index 0000000..44d1527 --- /dev/null +++ b/tests/helpers/metadata.ts @@ -0,0 +1,28 @@ +/* eslint-disable @typescript-eslint/no-magic-numbers */ +import { LAYER_3D_PRODUCT_TYPE_LIST } from '@map-colonies/3d-shared'; + +export const buildValidMetadata = (): Record => ({ + productId: 'p-1', + productName: 'afula', + productType: LAYER_3D_PRODUCT_TYPE_LIST[0], + classification: 'abc123', + srsId: '4326', + srsName: 'WGS84GEO', + region: ['ישראל'], + producerName: 'IDFMU', + productionSystem: 'sys', + productionSystemVersion: '1', + productionDate: '2025-07-08T11:26:00.000Z', + footprint: { + type: 'Polygon', + coordinates: [ + [ + [34.45, 31.48], + [34.46, 31.48], + [34.45, 31.48], + ], + ], + }, + imagingTimeBeginUTC: '2025-07-06T11:10:00.000Z', + imagingTimeEndUTC: '2025-07-10T11:10:00.000Z', +}); diff --git a/tests/integration/record/record.spec.ts b/tests/integration/record/record.spec.ts index 9ac5a72..0a1ccfb 100644 --- a/tests/integration/record/record.spec.ts +++ b/tests/integration/record/record.spec.ts @@ -1,5 +1,5 @@ import { jsLogger } from '@map-colonies/js-logger'; -import { describe, beforeEach, it, expect, beforeAll } from 'vitest'; +import { describe, beforeEach, it, expect, beforeAll, vi } from 'vitest'; import { trace } from '@opentelemetry/api'; import httpStatusCodes from 'http-status-codes'; import { createRequestSender, type RequestSender } from '@map-colonies/openapi-supertest'; @@ -7,11 +7,19 @@ import type { paths, operations } from '@openapi'; import { getApp } from '@src/app'; import { SERVICES } from '@common/constants'; import { initConfig } from '@src/common/config'; +import { LookupTablesCall } from '@src/externalServices/lookupTables/lookupTablesCall'; +import { CatalogCall } from '@src/externalServices/catalog/catalogCall'; +import { buildValidMetadata } from '@tests/helpers/metadata'; + +const lookupStub = { getClassifications: vi.fn().mockResolvedValue(['abc123']) } as unknown as LookupTablesCall; +const catalogStub = { findRecords: vi.fn().mockResolvedValue([]) } as unknown as CatalogCall; + +const validMetadata = buildValidMetadata(); const validIngestionPayload = { modelPath: '/shared/models/afula', tilesetFilename: 'tileset.json', - metadata: { productName: 'afula', productType: 'PHOTO_REALISTIC' }, + metadata: validMetadata, }; describe('record', function () { @@ -26,6 +34,8 @@ describe('record', function () { override: [ { token: SERVICES.LOGGER, provider: { useValue: await jsLogger({ enabled: false }) } }, { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, + { token: LookupTablesCall, provider: { useValue: lookupStub } }, + { token: CatalogCall, provider: { useValue: catalogStub } }, ], useChild: true, }); @@ -54,6 +64,15 @@ describe('record', function () { expect(response).toSatisfyApiSpec(); expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); }); + + it('should return 400 when metadata fails business validation', async function () { + const response = await requestSender.createRecord({ + requestBody: { ...validIngestionPayload, metadata: { ...validMetadata, productType: 'NOT_A_3D_TYPE' } }, + }); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + }); }); describe('DELETE /record/{id}', function () { diff --git a/tests/unit/externalServices/catalog/catalogCall.spec.ts b/tests/unit/externalServices/catalog/catalogCall.spec.ts new file mode 100644 index 0000000..94d45ec --- /dev/null +++ b/tests/unit/externalServices/catalog/catalogCall.spec.ts @@ -0,0 +1,36 @@ +import axios from 'axios'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { jsLogger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { CatalogCall } from '@src/externalServices/catalog/catalogCall'; +import { AppError } from '@src/common/appError'; +import type { ConfigType } from '@src/common/config'; + +vi.mock('axios'); +const mockedAxios = vi.mocked(axios, true); + +const configStub = { get: (): unknown => 'http://catalog' } as unknown as ConfigType; + +describe('CatalogCall', function () { + let client: CatalogCall; + + beforeEach(async function () { + vi.clearAllMocks(); + client = new CatalogCall(configStub, await jsLogger({ enabled: false })); + }); + + it('should return the records found by the catalog service', async function () { + mockedAxios.post.mockResolvedValue({ status: StatusCodes.OK, data: [{ id: '1', productName: 'afula' }] }); + + const records = await client.findRecords({ productName: 'afula' }); + + expect(records).toHaveLength(1); + expect(records[0].productName).toBe('afula'); + }); + + it('should throw an AppError when the catalog service fails', async function () { + mockedAxios.post.mockRejectedValue(new Error('service down')); + + await expect(client.findRecords({ productName: 'afula' })).rejects.toThrow(AppError); + }); +}); diff --git a/tests/unit/externalServices/lookupTables/lookupTablesCall.spec.ts b/tests/unit/externalServices/lookupTables/lookupTablesCall.spec.ts new file mode 100644 index 0000000..17ae1f8 --- /dev/null +++ b/tests/unit/externalServices/lookupTables/lookupTablesCall.spec.ts @@ -0,0 +1,37 @@ +import axios from 'axios'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { jsLogger } from '@map-colonies/js-logger'; +import { LookupTablesCall } from '@src/externalServices/lookupTables/lookupTablesCall'; +import { AppError } from '@src/common/appError'; +import type { ConfigType } from '@src/common/config'; + +vi.mock('axios'); +const mockedAxios = vi.mocked(axios, true); + +const configStub = { get: (): unknown => ({ url: 'http://lookup', subUrl: 'lookup-tables/lookupData' }) } as unknown as ConfigType; + +describe('LookupTablesCall', function () { + let client: LookupTablesCall; + + beforeEach(async function () { + vi.clearAllMocks(); + client = new LookupTablesCall(configStub, await jsLogger({ enabled: false })); + }); + + it('should return the classification values from the service', async function () { + mockedAxios.get.mockResolvedValue({ + data: [ + { value: 'a', translationCode: 'x' }, + { value: 'b', translationCode: 'y' }, + ], + }); + + await expect(client.getClassifications()).resolves.toEqual(['a', 'b']); + }); + + it('should throw an AppError when the lookup-tables service fails', async function () { + mockedAxios.get.mockRejectedValue(new Error('service down')); + + await expect(client.getClassifications()).rejects.toThrow(AppError); + }); +}); diff --git a/tests/unit/record/models/recordManager.spec.ts b/tests/unit/record/models/recordManager.spec.ts index a6d87ab..fde0ed2 100644 --- a/tests/unit/record/models/recordManager.spec.ts +++ b/tests/unit/record/models/recordManager.spec.ts @@ -1,23 +1,26 @@ import { jsLogger } from '@map-colonies/js-logger'; -import { describe, it, expect, beforeEach } from 'vitest'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; import { RecordManager, type IngestionPayload } from '@src/record/models/recordManager'; +import type { ValidationManager } from '@src/validator/validationManager'; + +const noopValidator = { validateIngestion: vi.fn().mockResolvedValue(undefined) } as unknown as ValidationManager; describe('RecordManager', function () { let manager: RecordManager; beforeEach(async function () { - manager = new RecordManager(await jsLogger({ enabled: false })); + manager = new RecordManager(await jsLogger({ enabled: false }), noopValidator); }); describe('createIngestion', function () { - it('should return a job response with a jobId and status', function () { + it('should return a job response with a jobId and status', async function () { const payload: IngestionPayload = { modelPath: '/shared/models/afula', tilesetFilename: 'tileset.json', metadata: { productName: 'afula' }, }; - const result = manager.createIngestion(payload); + const result = await manager.createIngestion(payload); expect(result.jobId).toBeTypeOf('string'); expect(result.status).toBeTypeOf('string'); diff --git a/tests/unit/validator/validationManager.spec.ts b/tests/unit/validator/validationManager.spec.ts new file mode 100644 index 0000000..c2638a8 --- /dev/null +++ b/tests/unit/validator/validationManager.spec.ts @@ -0,0 +1,88 @@ +import { jsLogger } from '@map-colonies/js-logger'; +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import { StatusCodes } from 'http-status-codes'; +import { + ValidationManager, + ERROR_METADATA_DATE, + ERROR_METADATA_FOOTPRINT, + ERROR_METADATA_MISSING_DATE, + ERROR_METADATA_INVALID_DATE, +} from '@src/validator/validationManager'; +import { AppError } from '@src/common/appError'; +import type { LookupTablesCall } from '@src/externalServices/lookupTables/lookupTablesCall'; +import type { CatalogCall } from '@src/externalServices/catalog/catalogCall'; +import { buildValidMetadata as validMetadata } from '@tests/helpers/metadata'; + +const lookupStub = { getClassifications: vi.fn().mockResolvedValue(['abc123']) } as unknown as LookupTablesCall; + +describe('ValidationManager', function () { + let validator: ValidationManager; + let catalogStub: CatalogCall; + + beforeEach(async function () { + catalogStub = { findRecords: vi.fn().mockResolvedValue([]) } as unknown as CatalogCall; + validator = new ValidationManager(await jsLogger({ enabled: false }), lookupStub, catalogStub); + }); + + it('should pass a fully valid metadata object', async function () { + await expect(validator.validateIngestion(validMetadata())).resolves.toBeUndefined(); + }); + + it('should throw 400 when a required core field is missing', async function () { + const metadata = validMetadata(); + delete metadata.productName; + + await expect(validator.validateIngestion(metadata)).rejects.toThrow(AppError); + }); + + it('should throw 400 for an invalid productType', async function () { + const metadata = { ...validMetadata(), productType: 'NOT_A_3D_TYPE' }; + + let thrown: unknown; + try { + await validator.validateIngestion(metadata); + } catch (err) { + thrown = err; + } + + expect(thrown).toBeInstanceOf(AppError); + expect((thrown as AppError).status).toBe(StatusCodes.BAD_REQUEST); + }); + + it('should throw the footprint error for a malformed geometry', async function () { + const metadata = { ...validMetadata(), footprint: { type: 'Point', coordinates: [34.45, 31.48] } }; + + await expect(validator.validateIngestion(metadata)).rejects.toThrow(ERROR_METADATA_FOOTPRINT); + }); + + it('should throw the date error when imagingTimeBeginUTC is after imagingTimeEndUTC', async function () { + const metadata = { ...validMetadata(), imagingTimeBeginUTC: '2025-07-11T00:00:00.000Z' }; + + await expect(validator.validateIngestion(metadata)).rejects.toThrow(ERROR_METADATA_DATE); + }); + + it('should throw the missing-date error when a source date is absent', async function () { + const metadata = validMetadata(); + delete metadata.imagingTimeEndUTC; + + await expect(validator.validateIngestion(metadata)).rejects.toThrow(ERROR_METADATA_MISSING_DATE); + }); + + it('should throw the invalid-date error for an unparseable date', async function () { + const metadata = { ...validMetadata(), imagingTimeBeginUTC: 'not-a-date' }; + + await expect(validator.validateIngestion(metadata)).rejects.toThrow(ERROR_METADATA_INVALID_DATE); + }); + + it('should throw 400 when the classification is not in the lookup table', async function () { + const metadata = { ...validMetadata(), classification: 'not-a-real-classification' }; + + await expect(validator.validateIngestion(metadata)).rejects.toThrow(AppError); + }); + + it('should throw 400 when the product name already exists in the catalog', async function () { + (catalogStub.findRecords as ReturnType).mockResolvedValueOnce([{ id: 'existing', productName: 'afula' }]); + + await expect(validator.validateIngestion(validMetadata())).rejects.toThrow(AppError); + }); +});