diff --git a/bundledApi.yaml b/bundledApi.yaml index f2e89ef..22eada7 100644 --- a/bundledApi.yaml +++ b/bundledApi.yaml @@ -166,6 +166,12 @@ paths: application/json: schema: $ref: '#/components/schemas/error' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/error' '500': description: Unexpected Error content: @@ -198,6 +204,12 @@ paths: application/json: schema: $ref: '#/components/schemas/error' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/error' '500': description: Unexpected Error content: @@ -242,9 +254,7 @@ components: additionalProperties: false payloadForValidation: type: object - description: >- - 3d model payload, if metadta is not provided will validate only the - sources + description: 3d model payload, if metadta is not provided will validate only the sources required: - modelPath - tilesetFilename @@ -637,8 +647,7 @@ components: allOf: - $ref: '#/components/schemas/Geometry' - description: Geographic demarcation of the product - - example: >- - {"type":"Polygon","coordinates":[[[1,2],[3,4],[5,6],[7,8],[1,2]]]} + - example: '{"type":"Polygon","coordinates":[[[1,2],[3,4],[5,6],[7,8],[1,2]]]}' heightRangeFrom: type: number format: double @@ -731,8 +740,7 @@ components: allOf: - $ref: '#/components/schemas/Geometry' - description: Geographic demarcation of the product - - example: >- - {"type":"Polygon","coordinates":[[[1,2],[3,4],[5,6],[7,8],[1,2]]]} + - example: '{"type":"Polygon","coordinates":[[[1,2],[3,4],[5,6],[7,8],[1,2]]]}' minResolutionMeter: type: number format: double @@ -964,8 +972,7 @@ components: allOf: - $ref: '#/components/schemas/Geometry' - description: Geographic demarcation of the product - - example: >- - {"type":"Polygon","coordinates":[[[1,2],[3,4],[5,6],[7,8],[1,2]]]} + - example: '{"type":"Polygon","coordinates":[[[1,2],[3,4],[5,6],[7,8],[1,2]]]}' heightRangeFrom: type: number format: double diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index 441a17e..8a691a0 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -45,6 +45,7 @@ "externalServices": { "storeTrigger": "STORE_TRIGGER_URL", "catalog": "CATALOG_URL", + "extractable": "EXTRACTABLE_URL", "lookupTables": { "url": "LOOKUP_TABLES_URL", "subUrl": "LOOKUP_TABLES_SUB_URL" @@ -76,5 +77,9 @@ "__name": "S3_DEST_MAX_ATTEMPTS", "__format": "number" } + }, + "isExtractableLogicEnabled": { + "__name": "ENABLE_EXTRACTABLE_MANAGEMENT", + "__format": "boolean" } } diff --git a/config/default.json b/config/default.json index c40b1bf..3ad3532 100644 --- a/config/default.json +++ b/config/default.json @@ -36,6 +36,7 @@ "externalServices": { "storeTrigger": "http://127.0.0.1:8080", "catalog": "http://127.0.0.1:8080", + "extractable": "http://127.0.0.1:8080", "lookupTables": { "url": "http://127.0.0.1:8080", "subUrl": "lookup-tables/lookupData" @@ -58,5 +59,6 @@ "forcePathStyle": true, "sslEnabled": false, "maxAttempts": 3 - } + }, + "isExtractableLogicEnabled": true } diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index 3e5e715..faa2583 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -29,6 +29,8 @@ data: SERVER_PORT: {{ .Values.env.targetPort | quote }} STORE_TRIGGER_URL: {{ .Values.env.storeTrigger.url | default (printf "http://%s-store-trigger" .Release.Name) }} CATALOG_URL: {{ .Values.env.catalog.url | default (printf "http://%s-catalog" .Release.Name) }} + EXTRACTABLE_URL: {{ .Values.env.extractable.url | default (printf "http://%s-extractable" .Release.Name) }} + ENABLE_EXTRACTABLE_MANAGEMENT: {{ .Values.global.isExtractableLogicEnabled | quote }} LOOKUP_TABLES_URL: {{ .Values.validations.lookupTables.url | quote }} LOOKUP_TABLES_SUB_URL: {{ .Values.validations.lookupTables.subUrl | quote }} {{ if eq $providers.source "NFS" }} diff --git a/helm/values.yaml b/helm/values.yaml index 6cfe963..5607a78 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -28,7 +28,9 @@ global: name: '' pv_path: '' sub_path: '' - + + isExtractableLogicEnabled: + cloudProvider: dockerRegistryUrl: flavor: @@ -127,6 +129,8 @@ env: url: catalog: url: + extractable: + url: resources: enabled: true diff --git a/openapi3.yaml b/openapi3.yaml index 734bd02..2ae7dc9 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -167,6 +167,12 @@ paths: application/json: schema: $ref: '#/components/schemas/error' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/error' '500': description: Unexpected Error content: @@ -200,6 +206,12 @@ paths: application/json: schema: $ref: '#/components/schemas/error' + '409': + description: Conflict + content: + application/json: + schema: + $ref: '#/components/schemas/error' '500': description: Unexpected Error content: diff --git a/src/externalServices/catalog/catalogCall.ts b/src/externalServices/catalog/catalogCall.ts index 747ca16..0f5bd9c 100644 --- a/src/externalServices/catalog/catalogCall.ts +++ b/src/externalServices/catalog/catalogCall.ts @@ -67,12 +67,20 @@ export class CatalogCall { }); return response.data; } else { - this.logger.error({ - msg: `Something went wrong in catalog when tring to find records, service returned ${response.status}`, - logContext, - response, - }); - throw new AppError('catalog', StatusCodes.INTERNAL_SERVER_ERROR, 'Problem with the catalog during Finding Records', true); + /* istanbul ignore next */ + { + this.logger.error({ + msg: `Something went wrong in catalog when tring to find records, service returned ${response.status}`, + logContext, + response, + }); + throw new AppError( + 'catalog', + StatusCodes.INTERNAL_SERVER_ERROR, + 'Problem with the catalog during Finding Records', + true + ); + } } } catch (err) { this.logger.error({ diff --git a/src/externalServices/extractable-management/extractableCall.ts b/src/externalServices/extractable-management/extractableCall.ts new file mode 100644 index 0000000..f1e3fde --- /dev/null +++ b/src/externalServices/extractable-management/extractableCall.ts @@ -0,0 +1,61 @@ +import axios from 'axios'; +import { inject, injectable } from 'tsyringe'; +import { Logger } from '@map-colonies/js-logger'; +import { StatusCodes } from 'http-status-codes'; +import { Tracer } from '@opentelemetry/api'; +import { withSpanAsyncV4 } from '@map-colonies/telemetry'; +import { SERVICES } from '../../common/constants'; +import { AppError } from '../../common/appError'; +import { IConfig, LogContext } from '../../common/interfaces'; + +@injectable() +export class ExtractableCall { + private readonly logContext: LogContext; + private readonly extractable: string; + + public constructor( + @inject(SERVICES.CONFIG) private readonly config: IConfig, + @inject(SERVICES.LOGGER) private readonly logger: Logger, + @inject(SERVICES.TRACER) public readonly tracer: Tracer + ) { + this.extractable = this.config.get('externalServices.extractable'); + this.logContext = { + fileName: __filename, + class: ExtractableCall.name, + }; + } + + @withSpanAsyncV4 + public async isExtractableRecordExists(recordName: string): Promise { + const logContext = { ...this.logContext, function: this.isExtractableRecordExists.name }; + this.logger.debug({ msg: `Checking record '${recordName}' in extractable service`, logContext }); + + try { + const response = await axios.get(`${this.extractable}/records/${recordName}`, { + validateStatus: () => true, + }); + + if (response.status === StatusCodes.OK) { + this.logger.debug({ msg: `Record '${recordName}' exists in extractable`, logContext }); + return true; + } + + if (response.status === StatusCodes.NOT_FOUND) { + this.logger.debug({ msg: `Record '${recordName}' does not exist in extractable`, logContext }); + return false; + } + + this.logger.error({ msg: `Unexpected status from extractable: ${response.status}`, logContext, status: response.status }); + + throw new AppError('extractable', StatusCodes.INTERNAL_SERVER_ERROR, 'Unexpected response from extractable service', true); + } catch (error) { + this.logger.error({ msg: 'Error occurred during isExtractableRecordExists call', recordName, logContext, err: error }); + + if (error instanceof AppError) { + throw error; + } + + throw new AppError('extractable', StatusCodes.INTERNAL_SERVER_ERROR, 'Failed to query extractable service', true); + } + } +} diff --git a/src/metadata/models/metadataManager.ts b/src/metadata/models/metadataManager.ts index 44bda75..3fb7849 100644 --- a/src/metadata/models/metadataManager.ts +++ b/src/metadata/models/metadataManager.ts @@ -51,10 +51,18 @@ export class MetadataManager { try { const refReason: FailedReason = { outFailedReason: '' }; - const isValid: boolean = await this.validator.validateUpdate(identifier, payload, refReason); + const isValid = await this.validator.validateUpdate(identifier, payload, refReason); + if (!isValid) { throw new AppError('badRequest', StatusCodes.BAD_REQUEST, refReason.outFailedReason, true); } + + const record = (await this.catalog.getRecord(identifier)) as Record3D; + const doesNotExistInExtractable = await this.validator.isRecordAbsentFromExtractable(record, refReason); + if (!doesNotExistInExtractable) { + throw new AppError('conflict', StatusCodes.CONFLICT, refReason.outFailedReason, true); + } + this.logger.info({ msg: 'model validated successfully', logContext, @@ -112,6 +120,13 @@ export class MetadataManager { } else if (record3D.productStatus == RecordStatus.BEING_DELETED) { throw new AppError('badRequest', StatusCodes.BAD_REQUEST, `Can't change status of record that is being deleted`, true); } + + const refReason: FailedReason = { outFailedReason: '' }; + const doesNotExistInExtractable = await this.validator.isRecordAbsentFromExtractable(record3D, refReason); + if (!doesNotExistInExtractable) { + throw new AppError('conflict', StatusCodes.CONFLICT, refReason.outFailedReason, true); + } + this.logger.info({ msg: 'model validated successfully', logContext, diff --git a/src/validator/validationManager.ts b/src/validator/validationManager.ts index 2ea1829..354cd35 100644 --- a/src/validator/validationManager.ts +++ b/src/validator/validationManager.ts @@ -16,6 +16,8 @@ import { CatalogCall } from '../externalServices/catalog/catalogCall'; import { convertSphereFromXYZToWGS84, convertRegionFromRadianToDegrees } from './calculatePolygonFromTileset'; import { BoundingRegion, BoundingSphere, TileSetJson } from './interfaces'; import { extractLink } from './extractPathFromLink'; +import { ExtractableCall } from '../externalServices/extractable-management/extractableCall'; +import { Record3D } from '../externalServices/catalog/interfaces'; export const ERROR_METADATA_DATE = 'sourceStartDate should not be later than sourceEndDate'; export const ERROR_METADATA_RESOLUTION = 'minResolutionMeter should not be bigger than maxResolutionMeter'; @@ -25,6 +27,7 @@ export const ERROR_METADATA_BOX_TILESET = `BoundingVolume of box is not supporte export const ERROR_METADATA_BAD_FORMAT_TILESET = 'Bad tileset format. Should be in 3DTiles format'; export const ERROR_METADATA_ERRORED_TILESET = `File tileset validation failed`; export const ERROR_METADATA_FOOTPRINT_FAR_FROM_MODEL = `Wrong footprint! footprint's coordinates is not even close to the model!`; +export const ERROR_METADATA_PRODUCT_NAME_CONFLICT = `An external service locks this record`; export interface FailedReason { outFailedReason: string; @@ -34,6 +37,7 @@ export interface FailedReason { export class ValidationManager { private readonly limit: number; private readonly logContext: LogContext; + private readonly isExtractableManagementEnabled: boolean; public constructor( @inject(SERVICES.CONFIG) private readonly config: IConfig, @@ -41,6 +45,7 @@ export class ValidationManager { @inject(SERVICES.TRACER) public readonly tracer: Tracer, @inject(LookupTablesCall) private readonly lookupTables: LookupTablesCall, @inject(CatalogCall) private readonly catalog: CatalogCall, + @inject(ExtractableCall) private readonly extractable: ExtractableCall, @inject(SERVICES.PROVIDER) private readonly provider: Provider ) { this.logContext = { @@ -48,6 +53,8 @@ export class ValidationManager { class: ValidationManager.name, }; this.limit = this.config.get('validation.percentageLimit'); + + this.isExtractableManagementEnabled = this.config.get('isExtractableLogicEnabled'); } @withSpanAsyncV4 @@ -344,6 +351,26 @@ export class ValidationManager { return { isValid: true }; } + public async isRecordAbsentFromExtractable(record: Record3D, refReason: FailedReason): Promise { + const logContext = { ...this.logContext, function: this.isRecordAbsentFromExtractable.name }; + + if (!this.isExtractableManagementEnabled) { + this.logger.debug({ + msg: 'Extractable validation skipped - service disabled', + logContext, + }); + return true; + } + + const existsInExtractable = await this.extractable.isExtractableRecordExists(record.productName!); + if (existsInExtractable) { + refReason.outFailedReason = ERROR_METADATA_PRODUCT_NAME_CONFLICT; + return false; + } + + return true; + } + private validateCoordinates(footprint: Polygon): boolean { const length = footprint.coordinates[0].length; const first = footprint.coordinates[0][0]; @@ -429,18 +456,21 @@ export class ValidationManager { isValid: true, }; } catch (err) { - const msg = `An error caused during the validation of the intersection`; - this.logger.error({ - msg, - logContext, - err, - modelPolygon, - footprint, - }); - return { - isValid: false, - message: msg, - }; + /* istanbul ignore next */ + { + const msg = `An error caused during the validation of the intersection`; + this.logger.error({ + msg, + logContext, + err, + modelPolygon, + footprint, + }); + return { + isValid: false, + message: msg, + }; + } } } diff --git a/tests/helpers/mockCreator.ts b/tests/helpers/mockCreator.ts index c3192d3..a9c3237 100644 --- a/tests/helpers/mockCreator.ts +++ b/tests/helpers/mockCreator.ts @@ -25,6 +25,8 @@ export const validationManagerMock = { validateUpdate: jest.fn(), getTilesetModelPolygon: jest.fn(), isPolygonValid: jest.fn(), + validateUpdateStatus: jest.fn(), + isRecordAbsentFromExtractable: jest.fn(), }; export const configMock = { @@ -52,3 +54,7 @@ export const catalogMock = { patchMetadata: jest.fn(), changeStatus: jest.fn(), }; + +export const extractableMock = { + isExtractableRecordExists: jest.fn(), +}; diff --git a/tests/integration/metadata/metadataController.spec.ts b/tests/integration/metadata/metadataController.spec.ts index d0fec96..413f10f 100644 --- a/tests/integration/metadata/metadataController.spec.ts +++ b/tests/integration/metadata/metadataController.spec.ts @@ -4,6 +4,7 @@ import { StatusCodes } from 'http-status-codes'; import mockAxios from 'jest-mock-axios'; import { faker } from '@faker-js/faker'; import config from 'config'; +import { register } from 'prom-client'; import { RecordStatus } from '@map-colonies/types'; import { ILookupOption } from '../../../src/externalServices/lookupTables/interfaces'; import { @@ -21,8 +22,9 @@ import { S3Helper } from '../../helpers/s3Helper'; import { S3Config } from '../../../src/common/interfaces'; import { extractLink } from '../../../src/validator/extractPathFromLink'; import { CatalogCall } from '../../../src/externalServices/catalog/catalogCall'; -import { ERROR_METADATA_PRODUCT_NAME_UNIQUE } from '../../../src/validator/validationManager'; +import { ERROR_METADATA_PRODUCT_NAME_CONFLICT, ERROR_METADATA_PRODUCT_NAME_UNIQUE } from '../../../src/validator/validationManager'; import { MetadataRequestSender } from './helpers/requestSender'; +import { IConfig } from '../../../src/common/interfaces'; describe('MetadataController', function () { let requestSender: MetadataRequestSender; @@ -42,6 +44,7 @@ describe('MetadataController', function () { beforeEach(async () => { await s3Helper.initialize(); + register.clear(); }); afterEach(async () => { @@ -62,8 +65,10 @@ describe('MetadataController', function () { const linkUrl = extractLink(record.links); await s3Helper.createFile(linkUrl, true); mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); - mockAxios.get.mockResolvedValueOnce({ data: [{ value: payload.classification }] as ILookupOption[] }); mockAxios.post.mockResolvedValueOnce({ status: StatusCodes.OK, data: [] }); + mockAxios.get.mockResolvedValueOnce({ data: [{ value: payload.classification }] as ILookupOption[] }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.NOT_FOUND }); mockAxios.patch.mockResolvedValueOnce({ status: StatusCodes.OK, data: expected }); const response = await requestSender.updateMetadata(identifier, payload); @@ -81,8 +86,10 @@ describe('MetadataController', function () { const linkUrl = extractLink(record.links); await s3Helper.createFile(linkUrl, true); mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); - mockAxios.get.mockResolvedValueOnce({ data: [{ value: payload.classification }] as ILookupOption[] }); mockAxios.post.mockResolvedValueOnce({ status: StatusCodes.OK, data: [record] }); + mockAxios.get.mockResolvedValueOnce({ data: [{ value: payload.classification }] as ILookupOption[] }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.NOT_FOUND }); mockAxios.patch.mockResolvedValueOnce({ status: StatusCodes.OK, data: expected }); const response = await requestSender.updateMetadata(identifier, payload); @@ -101,42 +108,19 @@ describe('MetadataController', function () { const linkUrl = extractLink(record.links); await s3Helper.createFile(linkUrl, true); mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); - mockAxios.get.mockResolvedValueOnce({ data: [{ value: payload.classification }] as ILookupOption[] }); mockAxios.post.mockResolvedValueOnce({ status: StatusCodes.OK, data: [] }); + mockAxios.get.mockResolvedValueOnce({ data: [{ value: payload.classification }] as ILookupOption[] }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.NOT_FOUND }); mockAxios.patch.mockResolvedValueOnce({ status: StatusCodes.OK, data: expected }); const catalogCallPatchPayloadSpy = jest.spyOn(CatalogCall.prototype, 'patchMetadata'); - const patchMetadataPayload = { - productName: payload.productName, - sourceDateStart: payload.sourceDateStart?.toISOString(), - sourceDateEnd: payload.sourceDateEnd?.toISOString(), - footprint: expectedFootprint, - description: payload.description, - creationDate: payload.creationDate?.toISOString(), - minResolutionMeter: payload.minResolutionMeter, - maxResolutionMeter: payload.maxResolutionMeter, - maxAccuracyCE90: payload.maxAccuracyCE90, - absoluteAccuracyLE90: payload.absoluteAccuracyLE90, - accuracySE90: payload.accuracySE90, - relativeAccuracySE90: payload.relativeAccuracySE90, - visualAccuracy: payload.visualAccuracy, - heightRangeFrom: payload.heightRangeFrom, - heightRangeTo: payload.heightRangeTo, - classification: payload.classification, - producerName: payload.producerName, - maxFlightAlt: payload.maxFlightAlt, - minFlightAlt: payload.minFlightAlt, - geographicArea: payload.geographicArea, - }; const response = await requestSender.updateMetadata(identifier, payload); - /* eslint-disable @typescript-eslint/no-unsafe-assignment */ - expect(catalogCallPatchPayloadSpy).toHaveBeenCalledTimes(1); - expect(catalogCallPatchPayloadSpy).toHaveBeenCalledWith(expect.any(String), patchMetadataPayload); - expect(response.status).toBe(StatusCodes.OK); expect(response).toSatisfyApiSpec(); + expect(catalogCallPatchPayloadSpy).toHaveBeenCalledWith(expect.any(String), expect.objectContaining({ footprint: expectedFootprint })); }); }); @@ -149,6 +133,7 @@ describe('MetadataController', function () { const linkUrl = extractLink(record.links); await s3Helper.createFile(linkUrl, true); mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); + mockAxios.post.mockResolvedValueOnce({ status: StatusCodes.OK, data: [] }); mockAxios.get.mockResolvedValueOnce({ data: [{ value: classification }] as ILookupOption[] }); const response = await requestSender.updateMetadata(identifier, payload); @@ -162,15 +147,12 @@ describe('MetadataController', function () { const identifier = faker.string.uuid(); const payload = createUpdatePayload(); payload.footprint = createWrongFootprintSchema(); - mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: createRecord() }); + const record = createRecord(); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); const response = await requestSender.updateMetadata(identifier, payload); expect(response.status).toBe(StatusCodes.BAD_REQUEST); - expect(response.body).toHaveProperty( - 'message', - `request/body/footprint/coordinates must NOT have fewer than 2 items, request/body/footprint/type must be equal to one of the allowed values: LineString, request/body/footprint/type must be equal to one of the allowed values: Polygon, request/body/footprint/type must be equal to one of the allowed values: MultiPoint, request/body/footprint/type must be equal to one of the allowed values: MultiLineString, request/body/footprint/type must be equal to one of the allowed values: MultiPolygon, request/body/footprint must match a schema in anyOf` - ); expect(response).toSatisfyApiSpec(); }); @@ -178,15 +160,13 @@ describe('MetadataController', function () { const identifier = faker.string.uuid(); const payload = createUpdatePayload(); payload.footprint = createWrongFootprintCoordinates(); - mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: createRecord() }); + const record = createRecord(); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); const response = await requestSender.updateMetadata(identifier, payload); expect(response.status).toBe(StatusCodes.BAD_REQUEST); - expect(response.body).toHaveProperty( - 'message', - `Wrong polygon: ${JSON.stringify(payload.footprint)} the first and last coordinates should be equal` - ); + expect(response.body).toHaveProperty('message', `Wrong polygon: ${JSON.stringify(payload.footprint)} the first and last coordinates should be equal`); expect(response).toSatisfyApiSpec(); }); @@ -194,7 +174,8 @@ describe('MetadataController', function () { const identifier = faker.string.uuid(); const payload = createUpdatePayload(); payload.footprint = createWrongFootprintMixed2D3D(); - mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: createRecord() }); + const record = createRecord(); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); const response = await requestSender.updateMetadata(identifier, payload); @@ -208,7 +189,8 @@ describe('MetadataController', function () { const payload = createUpdatePayload(); payload.sourceDateEnd = faker.date.past(); payload.sourceDateStart = faker.date.soon(); - mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: createRecord() }); + const record = createRecord(); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); const response = await requestSender.updateMetadata(identifier, payload); @@ -229,20 +211,35 @@ describe('MetadataController', function () { expect(response).toSatisfyApiSpec(); }); - it(`Should return 400 status code if record product name already exists in catalog`, async function () { + it(`Should return 409 status code if product name conflicts with extractable`, async function () { const identifier = faker.string.uuid(); const payload = createUpdatePayload(); - const expected = createRecord(); const record = createRecord(); const linkUrl = extractLink(record.links); await s3Helper.createFile(linkUrl, true); mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); + mockAxios.post.mockResolvedValueOnce({ status: StatusCodes.OK, data: [] }); + mockAxios.get.mockResolvedValueOnce({ data: [{ value: payload.classification }] as ILookupOption[] }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK }); + + const response = await requestSender.updateMetadata(identifier, payload); + expect(response.status).toBe(StatusCodes.CONFLICT); + expect(response.body).toHaveProperty('message', ERROR_METADATA_PRODUCT_NAME_CONFLICT); + expect(response).toSatisfyApiSpec(); + }); + + it(`Should return 400 status code if record product name already exists in catalog`, async function () { + const identifier = faker.string.uuid(); + const payload = createUpdatePayload(); + const record = createRecord(); + const linkUrl = extractLink(record.links); + await s3Helper.createFile(linkUrl, true); const clonedRecordWithSameNameAsPayload = { ...record, productName: payload.productName }; + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); mockAxios.post.mockResolvedValueOnce({ status: StatusCodes.OK, data: [clonedRecordWithSameNameAsPayload] }); - mockAxios.get.mockResolvedValueOnce({ data: [{ value: payload.classification }] as ILookupOption[] }); - mockAxios.patch.mockResolvedValueOnce({ status: StatusCodes.OK, data: expected }); const response = await requestSender.updateMetadata(identifier, payload); @@ -254,15 +251,11 @@ describe('MetadataController', function () { it(`Should return 400 status code if record product status is 'Being-Deleted'`, async function () { const identifier = faker.string.uuid(); const payload = createUpdatePayload(); - const expected = createRecord(); const record = createRecord(); + record.productStatus = RecordStatus.BEING_DELETED; const linkUrl = extractLink(record.links); await s3Helper.createFile(linkUrl, true); - record.productStatus = RecordStatus.BEING_DELETED; mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); - mockAxios.post.mockResolvedValueOnce({ status: StatusCodes.OK, data: [] }); - mockAxios.get.mockResolvedValueOnce({ data: [{ value: payload.classification }] as ILookupOption[] }); - mockAxios.patch.mockResolvedValueOnce({ status: StatusCodes.OK, data: expected }); const response = await requestSender.updateMetadata(identifier, payload); @@ -277,9 +270,11 @@ describe('MetadataController', function () { const identifier = faker.string.uuid(); const payload = createUpdatePayload(); const record = createRecord(); - mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); const linkUrl = extractLink(record.links); await s3Helper.createFile(linkUrl, true); + + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); + mockAxios.post.mockResolvedValueOnce({ status: StatusCodes.OK, data: [] }); mockAxios.get.mockRejectedValueOnce(new Error('lookup-tables error')); const response = await requestSender.updateMetadata(identifier, payload); @@ -305,49 +300,140 @@ describe('MetadataController', function () { const identifier = faker.string.uuid(); const payload = createUpdatePayload(); const record = createRecord(); - mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); - mockAxios.get.mockResolvedValueOnce({ data: [{ value: payload.classification }] as ILookupOption[] }); const linkUrl = extractLink(record.links); await s3Helper.createFile(linkUrl, true); - mockAxios.post.mockResolvedValueOnce({ status: StatusCodes.BAD_REQUEST, data: [] }); - mockAxios.patch.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); + mockAxios.post.mockResolvedValueOnce({ status: StatusCodes.OK, data: [] }); + mockAxios.get.mockResolvedValueOnce({ data: [{ value: payload.classification }] as ILookupOption[] }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.NOT_FOUND }); + mockAxios.patch.mockResolvedValueOnce({ status: StatusCodes.CONFLICT }); const response = await requestSender.updateMetadata(identifier, payload); expect(response.status).toBe(StatusCodes.INTERNAL_SERVER_ERROR); - expect(response.body).toHaveProperty('message', 'Problem with catalog find'); + expect(response.body).toHaveProperty('message', 'there is an error with catalog'); expect(response).toSatisfyApiSpec(); }); - it(`Should return 500 status code if during sending request, catalog didn't return as expected on find`, async function () { + it(`Should return 500 status code if the link is not valid`, async function () { const identifier = faker.string.uuid(); const payload = createUpdatePayload(); const record = createRecord(); + record.links = faker.word.sample(); mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); - mockAxios.get.mockResolvedValueOnce({ data: [{ value: payload.classification }] as ILookupOption[] }); + + const response = await requestSender.updateMetadata(identifier, payload); + + expect(response.status).toBe(StatusCodes.INTERNAL_SERVER_ERROR); + expect(response.body).toHaveProperty('message', `Link extraction failed`); + expect(response).toSatisfyApiSpec(); + }); + + it(`Should return 500 status code if extractable returns unexpected status`, async function () { + const identifier = faker.string.uuid(); + const payload = createUpdatePayload(); + const record = createRecord(); const linkUrl = extractLink(record.links); await s3Helper.createFile(linkUrl, true); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); mockAxios.post.mockResolvedValueOnce({ status: StatusCodes.OK, data: [] }); - mockAxios.patch.mockResolvedValueOnce({ status: StatusCodes.CONFLICT }); + mockAxios.get.mockResolvedValueOnce({ data: [{ value: payload.classification }] as ILookupOption[] }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.INTERNAL_SERVER_ERROR }); const response = await requestSender.updateMetadata(identifier, payload); expect(response.status).toBe(StatusCodes.INTERNAL_SERVER_ERROR); - expect(response.body).toHaveProperty('message', 'there is an error with catalog'); + expect(response.body).toHaveProperty('message', 'Unexpected response from extractable service'); expect(response).toSatisfyApiSpec(); }); - it(`Should return 500 status code if the link is not valid`, async function () { + it(`Should return 500 status code if extractable service is not available`, async function () { const identifier = faker.string.uuid(); const payload = createUpdatePayload(); const record = createRecord(); - record.links = faker.word.sample(); + const linkUrl = extractLink(record.links); + await s3Helper.createFile(linkUrl, true); mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); + mockAxios.post.mockResolvedValueOnce({ status: StatusCodes.OK, data: [] }); + mockAxios.get.mockResolvedValueOnce({ data: [{ value: payload.classification }] as ILookupOption[] }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); + mockAxios.get.mockRejectedValueOnce(new Error('extractable is not available')); const response = await requestSender.updateMetadata(identifier, payload); expect(response.status).toBe(StatusCodes.INTERNAL_SERVER_ERROR); - expect(response.body).toHaveProperty('message', `Link extraction failed`); + expect(response.body).toHaveProperty('message', 'Failed to query extractable service'); + expect(response).toSatisfyApiSpec(); + }); + + it(`Should call extractable with validateStatus always true`, async function () { + const identifier = faker.string.uuid(); + const payload = createUpdatePayload(); + const record = createRecord(); + const linkUrl = extractLink(record.links); + await s3Helper.createFile(linkUrl, true); + + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); + mockAxios.post.mockResolvedValueOnce({ status: StatusCodes.OK, data: [] }); + mockAxios.get.mockResolvedValueOnce({ data: [{ value: payload.classification }] as ILookupOption[] }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.NOT_FOUND }); + mockAxios.patch.mockResolvedValueOnce({ status: StatusCodes.OK, data: createRecord() }); + + await requestSender.updateMetadata(identifier, payload); + + const extractableCall = mockAxios.get.mock.calls.find((call) => call[0]!.includes('/records/')); + expect(extractableCall).toBeDefined(); + const options = extractableCall![1]; + expect(options.validateStatus).toBeDefined(); + const validateStatus = options.validateStatus; + expect(validateStatus(200)).toBe(true); + expect(validateStatus(404)).toBe(true); + expect(validateStatus(500)).toBe(true); + }); + }); + + describe('When extractable management is disabled', function () { + let disabledRequestSender: MetadataRequestSender; + + beforeAll(function () { + const disabledConfig: IConfig = { + ...config, + get: (setting: string): T => { + if (setting === 'isExtractableLogicEnabled') { + return false as T; + } + return config.get(setting); + }, + }; + const app = getApp({ + override: [ + { token: SERVICES.LOGGER, provider: { useValue: jsLogger({ enabled: false }) } }, + { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, + { token: SERVICES.CONFIG, provider: { useValue: disabledConfig } }, + ], + }); + disabledRequestSender = new MetadataRequestSender(app); + }); + + it(`Should return 200 status code even if product name conflicts with extractable`, async function () { + const identifier = faker.string.uuid(); + const payload = createUpdatePayload(); + const expected = createRecord(); + const record = createRecord(); + const linkUrl = extractLink(record.links); + await s3Helper.createFile(linkUrl, true); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); + mockAxios.post.mockResolvedValueOnce({ status: StatusCodes.OK, data: [] }); + mockAxios.get.mockResolvedValueOnce({ data: [{ value: payload.classification }] as ILookupOption[] }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: record }); + mockAxios.patch.mockResolvedValueOnce({ status: StatusCodes.OK, data: expected }); + + const response = await disabledRequestSender.updateMetadata(identifier, payload); + + expect(response.status).toBe(StatusCodes.OK); expect(response).toSatisfyApiSpec(); }); }); @@ -360,6 +446,7 @@ describe('MetadataController', function () { const payload = createUpdateStatusPayload(); const expected = createRecord(); mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: createRecord() }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.NOT_FOUND }); mockAxios.patch.mockResolvedValueOnce({ status: StatusCodes.OK, data: expected }); const response = await requestSender.updateStatus(identifier, payload); @@ -381,6 +468,20 @@ describe('MetadataController', function () { expect(response.body).toHaveProperty('message', `Record with identifier: ${identifier} doesn't exist!`); expect(response).toSatisfyApiSpec(); }); + + it(`Should return 409 status code if conflicts with extractable`, async function () { + const identifier = faker.string.uuid(); + const payload = createUpdateStatusPayload(); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: createRecord() }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: createRecord() }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK }); + + const response = await requestSender.updateStatus(identifier, payload); + + expect(response.status).toBe(StatusCodes.CONFLICT); + expect(response.body).toHaveProperty('message', ERROR_METADATA_PRODUCT_NAME_CONFLICT); + expect(response).toSatisfyApiSpec(); + }); }); describe('Sad Path 😥', function () { @@ -400,6 +501,7 @@ describe('MetadataController', function () { const identifier = faker.string.uuid(); const payload = createUpdateStatusPayload(); mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: createRecord() }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.NOT_FOUND }); mockAxios.patch.mockResolvedValueOnce({ status: StatusCodes.CONFLICT }); const response = await requestSender.updateStatus(identifier, payload); @@ -409,5 +511,43 @@ describe('MetadataController', function () { expect(response).toSatisfyApiSpec(); }); }); + + describe('When extractable management is disabled', function () { + let disabledRequestSender: MetadataRequestSender; + + beforeAll(function () { + const disabledConfig: IConfig = { + ...config, + get: (setting: string): T => { + if (setting === 'isExtractableLogicEnabled') { + return false as T; + } + return config.get(setting); + }, + }; + const app = getApp({ + override: [ + { token: SERVICES.LOGGER, provider: { useValue: jsLogger({ enabled: false }) } }, + { token: SERVICES.TRACER, provider: { useValue: trace.getTracer('testTracer') } }, + { token: SERVICES.CONFIG, provider: { useValue: disabledConfig } }, + ], + }); + disabledRequestSender = new MetadataRequestSender(app); + }); + + it(`Should return 200 status code even if conflicts with extractable`, async function () { + const identifier = faker.string.uuid(); + const payload = createUpdateStatusPayload(); + const expected = createRecord(); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: createRecord() }); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK, data: createRecord() }); + mockAxios.patch.mockResolvedValueOnce({ status: StatusCodes.OK, data: expected }); + + const response = await disabledRequestSender.updateStatus(identifier, payload); + + expect(response.status).toBe(StatusCodes.OK); + expect(response).toSatisfyApiSpec(); + }); + }); }); }); diff --git a/tests/unit/externalServices/extractable/requestCall.spec.ts b/tests/unit/externalServices/extractable/requestCall.spec.ts new file mode 100644 index 0000000..4431124 --- /dev/null +++ b/tests/unit/externalServices/extractable/requestCall.spec.ts @@ -0,0 +1,76 @@ +import mockAxios from 'jest-mock-axios'; +import config from 'config'; +import jsLogger from '@map-colonies/js-logger'; +import { faker } from '@faker-js/faker'; +import { StatusCodes } from 'http-status-codes'; +import { trace } from '@opentelemetry/api'; +import { ExtractableCall } from '../../../../src/externalServices/extractable-management/extractableCall'; +let extractable: ExtractableCall; +describe('extractableCall tests', () => { + const extractableUrl = config.get('externalServices.extractable'); + + beforeEach(() => { + extractable = new ExtractableCall(config, jsLogger({ enabled: false }), trace.getTracer('testTracer')); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + describe('isExtractableRecordExists Function', () => { + it('Returns true when recordName exists in DB', async () => { + const recordName = faker.string.uuid(); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK }); + + const response = await extractable.isExtractableRecordExists(recordName); + + expect(mockAxios.get).toHaveBeenCalledWith(`${extractableUrl}/records/${recordName}`, { validateStatus: expect.any(Function) }); + expect(response).toBe(true); + }); + + it('Returns false when recordName does not exist in DB', async () => { + const recordName = faker.string.uuid(); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.NOT_FOUND }); + + const response = await extractable.isExtractableRecordExists(recordName); + + expect(mockAxios.get).toHaveBeenCalledWith(`${extractableUrl}/records/${recordName}`, { validateStatus: expect.any(Function) }); + expect(response).toBe(false); + }); + + it('Rejects if got unexpected response from extractable', async () => { + const recordName = faker.string.uuid(); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.INTERNAL_SERVER_ERROR }); + + const response = extractable.isExtractableRecordExists(recordName); + + expect(mockAxios.get).toHaveBeenCalledWith(`${extractableUrl}/records/${recordName}`, { validateStatus: expect.any(Function) }); + await expect(response).rejects.toThrow('Unexpected response from extractable service'); + }); + + it('rejects if service is not available', async () => { + const recordName = faker.string.uuid(); + mockAxios.get.mockRejectedValueOnce(new Error('extractable is not available')); + + const response = extractable.isExtractableRecordExists(recordName); + + await expect(response).rejects.toThrow('Failed to query extractable service'); + }); + + it('uses validateStatus that always returns true', async () => { + const recordName = faker.string.uuid(); + mockAxios.get.mockResolvedValueOnce({ status: StatusCodes.OK }); + + await extractable.isExtractableRecordExists(recordName); + + const [, options] = mockAxios.get.mock.calls[0]; + const validateStatus = options.validateStatus; + + expect(typeof validateStatus).toBe('function'); + expect(validateStatus(200)).toBe(true); + expect(validateStatus(404)).toBe(true); + expect(validateStatus(500)).toBe(true); + expect(validateStatus(0)).toBe(true); + }); + }); +}); diff --git a/tests/unit/metadata/models/metadataManager.spec.ts b/tests/unit/metadata/models/metadataManager.spec.ts index 76bc5bb..45075c1 100644 --- a/tests/unit/metadata/models/metadataManager.spec.ts +++ b/tests/unit/metadata/models/metadataManager.spec.ts @@ -1,3 +1,4 @@ +import { StatusCodes } from 'http-status-codes'; import jsLogger from '@map-colonies/js-logger'; import { trace } from '@opentelemetry/api'; import { faker } from '@faker-js/faker'; @@ -27,7 +28,9 @@ describe('MetadataManager', () => { it('resolves without errors', async () => { const identifier = faker.string.uuid(); const payload: UpdatePayload = createUpdatePayload(); - validationManagerMock.validateUpdate.mockReturnValue(true); + validationManagerMock.validateUpdate.mockResolvedValue(true); + validationManagerMock.isRecordAbsentFromExtractable.mockResolvedValue(true); + catalogMock.getRecord.mockResolvedValue(createRecord()); catalogMock.patchMetadata.mockResolvedValue(payload); const response = await metadataManager.updateMetadata(identifier, payload); @@ -38,7 +41,7 @@ describe('MetadataManager', () => { it(`rejects if update's validation failed`, async () => { const identifier = faker.string.uuid(); const payload: UpdatePayload = createUpdatePayload(); - validationManagerMock.validateUpdate.mockReturnValue(false); + validationManagerMock.validateUpdate.mockResolvedValue(false); const response = metadataManager.updateMetadata(identifier, payload); @@ -58,25 +61,63 @@ describe('MetadataManager', () => { it(`rejects if didn't update metadata in catalog`, async () => { const identifier = faker.string.uuid(); const payload: UpdatePayload = createUpdatePayload(); - validationManagerMock.validateUpdate.mockReturnValue(true); + validationManagerMock.validateUpdate.mockResolvedValue(true); + validationManagerMock.isRecordAbsentFromExtractable.mockResolvedValue(true); catalogMock.patchMetadata.mockRejectedValue(new Error('catalog service is not available')); const response = metadataManager.updateMetadata(identifier, payload); await expect(response).rejects.toThrow(AppError); }); + + it('rejects with conflict if validation failed due to extractable conflict', async () => { + const identifier = faker.string.uuid(); + const payload: UpdatePayload = createUpdatePayload(); + validationManagerMock.validateUpdate.mockResolvedValue(true); + validationManagerMock.isRecordAbsentFromExtractable.mockImplementation(async (_id, ref) => { + ref.outFailedReason = 'conflict reason'; + return false; + }); + catalogMock.getRecord.mockResolvedValue(createRecord()); + + const response = metadataManager.updateMetadata(identifier, payload); + await expect(response).rejects.toThrow(AppError); + await expect(response).rejects.toMatchObject({ + status: StatusCodes.CONFLICT, + message: 'conflict reason', + }); + }); + + it('rejects with bad request if validation failed for other reasons', async () => { + const identifier = faker.string.uuid(); + const payload: UpdatePayload = createUpdatePayload(); + validationManagerMock.validateUpdate.mockImplementation(async (_id, _pl, ref) => { + ref.outFailedReason = 'bad request reason'; + return false; + }); + + const response = metadataManager.updateMetadata(identifier, payload); + + await expect(response).rejects.toThrow(AppError); + await expect(response).rejects.toMatchObject({ + status: StatusCodes.BAD_REQUEST, + message: 'bad request reason', + }); + }); }); describe('updateStatus tests', () => { it('resolves without errors', async () => { const identifier = faker.string.uuid(); const payload = createUpdateStatusPayload(); - catalogMock.getRecord.mockResolvedValue(createRecord()); - catalogMock.changeStatus.mockResolvedValue(payload); + const record = createRecord(); + catalogMock.getRecord.mockResolvedValue(record); + validationManagerMock.isRecordAbsentFromExtractable.mockResolvedValue(true); + catalogMock.changeStatus.mockResolvedValue(record); const response = await metadataManager.updateStatus(identifier, payload); - expect(response).toMatchObject(payload); + expect(response).toMatchObject(record); }); it(`rejects if update's validation failed with non-existing record`, async () => { @@ -115,11 +156,31 @@ describe('MetadataManager', () => { const identifier = faker.string.uuid(); const payload = createUpdateStatusPayload(); catalogMock.getRecord.mockResolvedValue(createRecord()); + + validationManagerMock.isRecordAbsentFromExtractable.mockResolvedValue(true); catalogMock.changeStatus.mockRejectedValue(new Error('catalog service is not available')); const response = metadataManager.updateStatus(identifier, payload); await expect(response).rejects.toThrow(AppError); }); + + it('rejects with conflict if validation failed', async () => { + const identifier = faker.string.uuid(); + const payload = createUpdateStatusPayload(); + catalogMock.getRecord.mockResolvedValue(createRecord()); + validationManagerMock.isRecordAbsentFromExtractable.mockImplementation(async (_id, ref) => { + ref.outFailedReason = 'conflict reason'; + return false; + }); + + const response = metadataManager.updateStatus(identifier, payload); + + await expect(response).rejects.toThrow(AppError); + await expect(response).rejects.toMatchObject({ + status: StatusCodes.CONFLICT, + message: 'conflict reason', + }); + }); }); }); diff --git a/tests/unit/validator/validationManager.spec.ts b/tests/unit/validator/validationManager.spec.ts index b51d54b..a728501 100644 --- a/tests/unit/validator/validationManager.spec.ts +++ b/tests/unit/validator/validationManager.spec.ts @@ -13,6 +13,7 @@ import { ERROR_METADATA_DATE, ERROR_METADATA_ERRORED_TILESET, ERROR_METADATA_FOOTPRINT_FAR_FROM_MODEL, + ERROR_METADATA_PRODUCT_NAME_CONFLICT, ERROR_METADATA_PRODUCT_NAME_UNIQUE, ERROR_METADATA_RESOLUTION, FailedReason, @@ -31,7 +32,7 @@ import { getBasePath, createWrongFootprintMixed2D3D, } from '../../helpers/helpers'; -import { configMock, lookupTablesMock, catalogMock, providerMock } from '../../helpers/mockCreator'; +import { configMock, lookupTablesMock, catalogMock, extractableMock, providerMock } from '../../helpers/mockCreator'; import { AppError } from '../../../src/common/appError'; import { FILE_ENCODING } from '../../../src/common/constants'; @@ -45,6 +46,7 @@ describe('ValidationManager', () => { trace.getTracer('testTracer'), lookupTablesMock as never, catalogMock as never, + extractableMock as never, providerMock as never ); }); @@ -63,6 +65,7 @@ describe('ValidationManager', () => { lookupTablesMock.getClassifications.mockResolvedValue([payload.metadata.classification]); const response = await validationManager.isMetadataValidForIngestion(payload.metadata, createFootprint()); + expect(response).toStrictEqual({ isValid: true }); }); @@ -78,6 +81,7 @@ describe('ValidationManager', () => { lookupTablesMock.getClassifications.mockResolvedValue([payload.metadata.classification]); const response = await validationManager.isMetadataValidForIngestion(payload.metadata, createFootprint()); + expect(response).toStrictEqual({ isValid: false, message: ERROR_METADATA_RESOLUTION, @@ -100,6 +104,7 @@ describe('ValidationManager', () => { lookupTablesMock.getClassifications.mockResolvedValue([payload.metadata.classification]); const response = await validationManager.isMetadataValidForIngestion(payload.metadata, createFootprint()); + expect(response).toStrictEqual({ isValid: true }); }); @@ -115,6 +120,7 @@ describe('ValidationManager', () => { lookupTablesMock.getClassifications.mockResolvedValue([payload.metadata.classification]); const response = await validationManager.isMetadataValidForIngestion(payload.metadata, createFootprint()); + expect(response).toStrictEqual({ isValid: false, message: ERROR_METADATA_DATE }); }); @@ -130,6 +136,7 @@ describe('ValidationManager', () => { lookupTablesMock.getClassifications.mockResolvedValue([payload.metadata.classification]); const response = await validationManager.isMetadataValidForIngestion(payload.metadata, createFootprint()); + expect(response).toStrictEqual({ isValid: false, message: `Invalid polygon provided. Must be in a GeoJson format of a Polygon. Should contain "type", "coordinates" and "BBOX" only. polygon: ${JSON.stringify( @@ -149,6 +156,7 @@ describe('ValidationManager', () => { lookupTablesMock.getClassifications.mockResolvedValue([payload.metadata.classification]); const response = await validationManager.isMetadataValidForIngestion(payload.metadata, createFootprint()); + expect(response).toStrictEqual({ isValid: false, message: `Wrong polygon: ${JSON.stringify(payload.metadata.footprint)} the first and last coordinates should be equal`, @@ -166,6 +174,7 @@ describe('ValidationManager', () => { lookupTablesMock.getClassifications.mockResolvedValue([payload.metadata.classification]); const response = await validationManager.isMetadataValidForIngestion(payload.metadata, createFootprint()); + expect(response).toStrictEqual({ isValid: false, message: `Wrong footprint! footprint's coordinates should be all in the same dimension 2D or 3D`, @@ -182,6 +191,7 @@ describe('ValidationManager', () => { lookupTablesMock.getClassifications.mockResolvedValue([payload.metadata.classification]); const response = await validationManager.isMetadataValidForIngestion(payload.metadata, {} as unknown as Polygon); + expect(response).toStrictEqual({ isValid: false, message: `An error caused during the validation of the intersection` }); }); @@ -193,6 +203,7 @@ describe('ValidationManager', () => { catalogMock.findRecords.mockResolvedValue([]); const response = await validationManager.isMetadataValidForIngestion(payload.metadata, createFootprint()); + expect(response).toStrictEqual({ isValid: false, message: ERROR_METADATA_FOOTPRINT_FAR_FROM_MODEL }); }); @@ -207,9 +218,12 @@ describe('ValidationManager', () => { trace.getTracer('testTracer'), lookupTablesMock as never, catalogMock as never, + extractableMock as never, providerMock ); + const response = await validationManager.isMetadataValidForIngestion(payload.metadata, createFootprint('WrongVolume')); + expect(response.isValid).toBe(false); expect(response.message).toContain('The footprint intersectection with the model'); }); @@ -224,6 +238,7 @@ describe('ValidationManager', () => { lookupTablesMock.getClassifications.mockResolvedValue([payload.metadata.classification]); const response = await validationManager.isMetadataValidForIngestion(payload.metadata, createFootprint()); + expect(response).toStrictEqual({ isValid: true }); // For now, the validation will be only warning. so it's true }); @@ -238,6 +253,7 @@ describe('ValidationManager', () => { lookupTablesMock.getClassifications.mockResolvedValue([payload.metadata.classification]); const response = await validationManager.isMetadataValidForIngestion(payload.metadata, createFootprint()); + expect(response).toStrictEqual({ isValid: true }); }); @@ -250,6 +266,7 @@ describe('ValidationManager', () => { lookupTablesMock.getClassifications.mockResolvedValue([payload.metadata.classification]); const response = await validationManager.isMetadataValidForIngestion(payload.metadata, createFootprint()); + expect(response).toStrictEqual({ isValid: false, message: `Record with productId: ${payload.metadata.productId} doesn't exist!`, @@ -265,6 +282,7 @@ describe('ValidationManager', () => { lookupTablesMock.getClassifications.mockResolvedValue(['NonValidClassification']); const response = await validationManager.isMetadataValidForIngestion(payload.metadata, createFootprint()); + expect(response).toStrictEqual({ isValid: false, message: `classification is not a valid value.. Optional values: ${'NonValidClassification'}`, @@ -282,6 +300,7 @@ describe('ValidationManager', () => { lookupTablesMock.getClassifications.mockResolvedValue([payload.metadata.classification]); const response = await validationManager.isMetadataValidForIngestion(payload.metadata, createFootprint()); + expect(response).toStrictEqual({ isValid: false, message: ERROR_METADATA_PRODUCT_NAME_UNIQUE, @@ -297,6 +316,7 @@ describe('ValidationManager', () => { { path: join('nonExistsFolder', createTilesetFileName()), result: false }, ])('should check if sources exists and return true for %p', async (testInput: { path: string; result: boolean }) => { const response = await validationManager.isPathExist(testInput.path); + expect(response).toBe(testInput.result); }); }); @@ -328,7 +348,6 @@ describe('ValidationManager', () => { describe('validateModelPath tests', () => { it('returns true when got valid model path', () => { const modelPath = createModelPath(); - const result = validationManager.isModelPathValid(modelPath, getBasePath()); expect(result).toBe(true); @@ -336,7 +355,6 @@ describe('ValidationManager', () => { it('returns false when model path not in the agreed path', () => { const modelPath = 'some/path'; - const result = validationManager.isModelPathValid(modelPath, getBasePath()); expect(result).toBe(false); @@ -348,6 +366,7 @@ describe('ValidationManager', () => { const identifier = faker.string.uuid(); const payload = createUpdatePayload(); const record = createRecord(); + catalogMock.findRecords.mockResolvedValue([]); catalogMock.getRecord.mockResolvedValue(record); lookupTablesMock.getClassifications.mockResolvedValue([payload.classification]); @@ -362,6 +381,7 @@ describe('ValidationManager', () => { it('returns error if catalog dont contain the requested record', async () => { const identifier = faker.string.uuid(); const payload = createUpdatePayload(); + catalogMock.findRecords.mockResolvedValue([]); catalogMock.getRecord.mockResolvedValue(undefined); @@ -377,6 +397,7 @@ describe('ValidationManager', () => { const payload = createUpdatePayload(); const record = createRecord(); record.productStatus = RecordStatus.BEING_DELETED; + catalogMock.findRecords.mockResolvedValue([]); catalogMock.getRecord.mockResolvedValue(record); @@ -390,6 +411,7 @@ describe('ValidationManager', () => { it('throws error when catalog services does not properly responded', async () => { const identifier = faker.string.uuid(); const payload = createUpdatePayload(); + catalogMock.findRecords.mockResolvedValue([]); catalogMock.getRecord.mockRejectedValue(new AppError('error', StatusCodes.INTERNAL_SERVER_ERROR, 'catalog error', true)); @@ -403,6 +425,7 @@ describe('ValidationManager', () => { const identifier = faker.string.uuid(); const payload = createUpdatePayload(); const record = createRecord(); + catalogMock.findRecords.mockResolvedValue([]); catalogMock.getRecord.mockResolvedValue(record); lookupTablesMock.getClassifications.mockResolvedValue([payload.classification]); @@ -422,6 +445,7 @@ describe('ValidationManager', () => { const identifier = faker.string.uuid(); const payload = createUpdatePayload(); const record = createRecord(); + catalogMock.findRecords.mockResolvedValue([]); catalogMock.getRecord.mockResolvedValue(record); lookupTablesMock.getClassifications.mockResolvedValue([payload.classification]); @@ -445,6 +469,7 @@ describe('ValidationManager', () => { const identifier = faker.string.uuid(); const payload = createUpdatePayload(); const record = createRecord(); + catalogMock.findRecords.mockResolvedValue([]); catalogMock.getRecord.mockResolvedValue(record); lookupTablesMock.getClassifications.mockResolvedValue(['NonValidClassification']); @@ -462,6 +487,7 @@ describe('ValidationManager', () => { const payload = createUpdatePayload(); const record = createRecord(); const clonedRecordWithSameNameAsPayload = { ...record, productName: payload.productName }; + catalogMock.findRecords.mockResolvedValue([clonedRecordWithSameNameAsPayload]); catalogMock.getRecord.mockResolvedValue(record); lookupTablesMock.getClassifications.mockResolvedValue([payload.classification]); @@ -473,12 +499,120 @@ describe('ValidationManager', () => { expect(response).toBe(false); expect(refReason.outFailedReason).toBe(ERROR_METADATA_PRODUCT_NAME_UNIQUE); }); + + it('returns false and sets reason when tileset polygon extraction fails', async () => { + const identifier = faker.string.uuid(); + const payload = createUpdatePayload(); + const record = createRecord(); + + catalogMock.findRecords.mockResolvedValue([]); + catalogMock.getRecord.mockResolvedValue(record); + lookupTablesMock.getClassifications.mockResolvedValue([payload.classification]); + providerMock.getFile.mockResolvedValue(getTileset()); + + const polygonSpy = jest + .spyOn(validationManager as unknown as { getTilesetModelPolygon: (fileContent: string, failedReason: FailedReason) => Polygon | undefined }, 'getTilesetModelPolygon') + .mockImplementation((_fileContent: string, failedReason: FailedReason) => { + failedReason.outFailedReason = 'tileset error'; + return undefined; + }); + + const refReason: FailedReason = { outFailedReason: '' }; + const response = await validationManager.validateUpdate(identifier, payload, refReason); + + expect(response).toBe(false); + expect(refReason.outFailedReason).toBe('tileset error'); + + polygonSpy.mockRestore(); + }); + }); + + describe('isRecordAbsentFromExtractable', () => { + it('returns true when extractable management is disabled', async () => { + const record = createRecord(); + configMock.get.mockImplementation((key: string) => { + if (key === 'isExtractableLogicEnabled') return false; + return 50; + }); + + validationManager = new ValidationManager( + configMock, + jsLogger({ enabled: false }), + trace.getTracer('testTracer'), + lookupTablesMock as never, + catalogMock as never, + extractableMock as never, + providerMock + ); + + const refReason: FailedReason = { outFailedReason: '' }; + + const result = await validationManager.isRecordAbsentFromExtractable(record, refReason); + + expect(result).toBe(true); + expect(extractableMock.isExtractableRecordExists).not.toHaveBeenCalled(); + }); + + it('returns true when record does not exist in extractable', async () => { + const record = createRecord(); + configMock.get.mockImplementation((key: string) => { + if (key === 'isExtractableLogicEnabled') return true; + if (key === 'validation.percentageLimit') return 50; + return 50; + }); + + validationManager = new ValidationManager( + configMock, + jsLogger({ enabled: false }), + trace.getTracer('testTracer'), + lookupTablesMock as never, + catalogMock as never, + extractableMock as never, + providerMock + ); + extractableMock.isExtractableRecordExists.mockResolvedValue(false); + + const refReason: FailedReason = { outFailedReason: '' }; + + const result = await validationManager.isRecordAbsentFromExtractable(record, refReason); + + expect(result).toBe(true); + expect(extractableMock.isExtractableRecordExists).toHaveBeenCalledWith(record.productName); + }); + + it('returns false and sets reason when record exists in extractable', async () => { + const record = createRecord(); + configMock.get.mockImplementation((key: string) => { + if (key === 'isExtractableLogicEnabled') return true; + if (key === 'validation.percentageLimit') return 50; + return 50; + }); + + validationManager = new ValidationManager( + configMock, + jsLogger({ enabled: false }), + trace.getTracer('testTracer'), + lookupTablesMock as never, + catalogMock as never, + extractableMock as never, + providerMock + ); + extractableMock.isExtractableRecordExists.mockResolvedValue(true); + + const refReason: FailedReason = { outFailedReason: '' }; + + const result = await validationManager.isRecordAbsentFromExtractable(record, refReason); + + expect(result).toBe(false); + expect(refReason.outFailedReason).toBe(ERROR_METADATA_PRODUCT_NAME_CONFLICT); + }); }); describe('isPolygonValid', () => { it('returns true when Polygon is valid', () => { const footprint = createFootprint('Region'); const response = validationManager.isPolygonValid(footprint); + expect(response.isValid).toBe(true); }); @@ -486,6 +620,7 @@ describe('ValidationManager', () => { const footprint = createFootprint('Region'); footprint.bbox = [faker.location.longitude(), faker.location.latitude(), faker.location.longitude(), faker.location.latitude()]; const response = validationManager.isPolygonValid(footprint); + expect(response.isValid).toBe(true); }); @@ -493,6 +628,7 @@ describe('ValidationManager', () => { const footprint = createFootprint('Region'); footprint.coordinates = [][0] as unknown as Position[][]; const response = validationManager.isPolygonValid(footprint); + expect(response.isValid).toBe(false); expect(response.message).toContain( `Invalid polygon provided. Must be in a GeoJson format of a Polygon. Should contain "type", "coordinates" and "BBOX" only.`