diff --git a/.github/workflows/pull_request.yaml b/.github/workflows/pull_request.yaml index 2c62401f..0c23d9b9 100644 --- a/.github/workflows/pull_request.yaml +++ b/.github/workflows/pull_request.yaml @@ -6,5 +6,4 @@ jobs: pull_request: uses: MapColonies/shared-workflows/.github/workflows/pull_request.yaml@v5 secrets: inherit - with: - chartDirs: 'helm' + diff --git a/config/custom-environment-variables.json b/config/custom-environment-variables.json index c2ae9096..8fc2238c 100644 --- a/config/custom-environment-variables.json +++ b/config/custom-environment-variables.json @@ -87,6 +87,7 @@ "jobDomain": "JOB_DOMAIN", "ingestionNewJobType": "INGESTION_NEW_JOB_TYPE", "validationTaskType": "VALIDATION_TASK_TYPE", + "finalizeTaskType": "FINALIZE_TASK_TYPE", "ingestionUpdateJobType": "INGESTION_UPDATE_JOB_TYPE", "ingestionSwapUpdateJobType": "INGESTION_SWAP_UPDATE_JOB_TYPE", "supportedIngestionSwapTypes": { diff --git a/config/default.json b/config/default.json index 53d9e9a7..159a16aa 100644 --- a/config/default.json +++ b/config/default.json @@ -63,6 +63,7 @@ "ingestionUpdateJobType": "Ingestion_Update", "ingestionSwapUpdateJobType": "Ingestion_Swap_Update", "validationTaskType": "validation", + "finalizeTaskType": "finalize", "supportedIngestionSwapTypes": [ { "productType": "RasterVectorBest", diff --git a/helm/templates/configmap.yaml b/helm/templates/configmap.yaml index 9a7492b7..b1f85d0e 100644 --- a/helm/templates/configmap.yaml +++ b/helm/templates/configmap.yaml @@ -37,6 +37,7 @@ data: INGESTION_UPDATE_JOB_TYPE: {{ $jobDefinitions.jobs.update.type | quote }} INGESTION_SWAP_UPDATE_JOB_TYPE: {{ $jobDefinitions.jobs.swapUpdate.type | quote }} VALIDATION_TASK_TYPE: {{ $jobDefinitions.tasks.validation.type | quote }} + FINALIZE_TASK_TYPE: {{ $jobDefinitions.tasks.finalize.type | quote }} CRS: {{ .Values.env.validationValuesByInfo.crs | toJson | quote}} FILE_FORMAT: {{ .Values.env.validationValuesByInfo.fileFormat | toJson | quote}} TILE_SIZE: {{ .Values.env.validationValuesByInfo.tileSize | quote}} diff --git a/helm/values.yaml b/helm/values.yaml index 0eefd8f6..106b10af 100644 --- a/helm/values.yaml +++ b/helm/values.yaml @@ -55,6 +55,8 @@ jobDefinitions: tasks: validation: type: "" + finalize: + type: "" storage: fs: diff --git a/openapi3.yaml b/openapi3.yaml index cb17ff4b..2a2ce3ff 100644 --- a/openapi3.yaml +++ b/openapi3.yaml @@ -50,7 +50,7 @@ paths: $ref: >- ./Schema/ingestionTrigger/responses/ingestionTriggerResponses.yaml#/components/schemas/errorMessage '500': - description: Server error + description: Internal Server Error content: application/json: schema: @@ -111,7 +111,7 @@ paths: $ref: >- ./Schema/ingestionTrigger/responses/ingestionTriggerResponses.yaml#/components/schemas/errorMessage '500': - description: Invalid request + description: Internal Server Error content: application/json: schema: @@ -157,8 +157,69 @@ paths: schema: $ref: >- ./Schema/ingestionTrigger/responses/ingestionTriggerResponses.yaml#/components/schemas/errorMessage + '422': + description: Unprocessable Content + content: + application/json: + schema: + $ref: >- + ./Schema/ingestionTrigger/responses/ingestionTriggerResponses.yaml#/components/schemas/errorMessage '500': - description: Internal Server error + description: Internal Server Error + content: + application/json: + schema: + $ref: >- + ./Schema/ingestionTrigger/responses/ingestionTriggerResponses.yaml#/components/schemas/errorMessage + /ingestion/{jobId}/abort: + put: + operationId: abortIngestion + tags: + - ingestion + summary: abort an active ingestion job + description: >- + Aborts an ingestion job that is currently active or pending, preventing any further processing. + parameters: + - name: jobId + in: path + description: The id of the job to abort + required: true + schema: + type: string + format: uuid + responses: + '200': + description: OK - Job aborted successfully + '400': + description: Bad request - Job status does not allow abort (COMPLETED or ABORTED) + content: + application/json: + schema: + $ref: >- + ./Schema/ingestionTrigger/responses/ingestionTriggerResponses.yaml#/components/schemas/errorMessage + '404': + description: Not Found - Job does not exist + content: + application/json: + schema: + $ref: >- + ./Schema/ingestionTrigger/responses/ingestionTriggerResponses.yaml#/components/schemas/errorMessage + '409': + description: Conflict - Unable to abort job due to current state (e.g., already completed or in finalization) + content: + application/json: + schema: + $ref: >- + ./Schema/ingestionTrigger/responses/ingestionTriggerResponses.yaml#/components/schemas/errorMessage + '422': + description: Unprocessable Content + content: + application/json: + schema: + $ref: >- + ./Schema/ingestionTrigger/responses/ingestionTriggerResponses.yaml#/components/schemas/errorMessage + '500': + description: Internal Server Error content: application/json: schema: @@ -188,7 +249,7 @@ paths: $ref: >- ./Schema/ingestionTrigger/responses/ingestionTriggerResponses.yaml#/components/schemas/validateSourcesResponse '400': - description: Invalid request + description: Bad Request content: application/json: schema: @@ -230,7 +291,7 @@ paths: $ref: >- ./Schema/ingestionTrigger/responses/ingestionTriggerResponses.yaml#/components/schemas/sourcesInfoResponse '400': - description: Invalid request + description: Bad Request content: application/json: schema: @@ -257,6 +318,7 @@ paths: schema: $ref: >- ./Schema/ingestionTrigger/responses/ingestionTriggerResponses.yaml#/components/schemas/errorMessage + components: schemas: CallbackUrls: diff --git a/package-lock.json b/package-lock.json index dd7abdc7..3a0f9add 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2545,6 +2545,8 @@ }, "node_modules/@humanwhocodes/momoa": { "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@humanwhocodes/momoa/-/momoa-2.0.4.tgz", + "integrity": "sha512-RE815I4arJFtt+FVeU1Tgp9/Xvecacji8w/V6XtXsWWH/wz/eNkNbhb+ny/+PlVZjV0rxQpRSQKNKE3lcktHEA==", "dev": true, "license": "Apache-2.0", "engines": { @@ -2571,7 +2573,9 @@ } }, "node_modules/@isaacs/brace-expansion": { - "version": "5.0.0", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/brace-expansion/-/brace-expansion-5.0.1.tgz", + "integrity": "sha512-WMz71T1JS624nWj2n2fnYAuPovhv7EUhk69R6i9dsVyzxt5eM3bjwvgk9L+APE1TRscGysAVMANkB0jh0LQZrQ==", "license": "MIT", "dependencies": { "@isaacs/balanced-match": "^4.0.1" @@ -2660,6 +2664,8 @@ }, "node_modules/@isaacs/fs-minipass": { "version": "4.0.1", + "resolved": "https://registry.npmjs.org/@isaacs/fs-minipass/-/fs-minipass-4.0.1.tgz", + "integrity": "sha512-wgm9Ehl2jpeqP3zw/7mo3kRHFp5MEDhqAdwy1fTGkHAwnkGOVsgpvQhL8B5n1qlb01jV3n/bI0ZfZp5lWA1k4w==", "license": "ISC", "dependencies": { "minipass": "^7.0.4" @@ -3116,6 +3122,8 @@ }, "node_modules/@jsep-plugin/assignment": { "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@jsep-plugin/assignment/-/assignment-1.3.0.tgz", + "integrity": "sha512-VVgV+CXrhbMI3aSusQyclHkenWSAm95WaiKrMxRFam3JSUiIaQjoMIw2sEs/OX4XifnqeQUN4DYbJjlA8EfktQ==", "dev": true, "license": "MIT", "engines": { @@ -3127,6 +3135,8 @@ }, "node_modules/@jsep-plugin/regex": { "version": "1.0.4", + "resolved": "https://registry.npmjs.org/@jsep-plugin/regex/-/regex-1.0.4.tgz", + "integrity": "sha512-q7qL4Mgjs1vByCaTnDFcBnV9HS7GVPJX5vyVoCgZHNSC9rjwIlmbXG5sUuorR5ndfHAIlJ8pVStxvjXHbNvtUg==", "dev": true, "license": "MIT", "engines": { @@ -4724,8 +4734,6 @@ }, "node_modules/@map-colonies/raster-shared": { "version": "7.8.0-alpha.1", - "resolved": "https://registry.npmjs.org/@map-colonies/raster-shared/-/raster-shared-7.8.0-alpha.1.tgz", - "integrity": "sha512-W//FWIGVQP674Siw2erS29edAlxv7LCGfCuMDeb+27W1SyY+saTwLHVVYGL20UQ++lMuLkmbqCtGcJxIKoX3oQ==", "license": "ISC", "dependencies": { "@map-colonies/mc-priority-queue": "^8.2.1", @@ -5185,71 +5193,88 @@ } }, "node_modules/@npmcli/agent": { - "version": "3.0.0", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/agent/-/agent-4.0.0.tgz", + "integrity": "sha512-kAQTcEN9E8ERLVg5AsGwLNoFb+oEG6engbqAU2P43gD4JEIkNGMHdVQ096FsOAAYpZPB0RSt0zgInKIAS1l5QA==", "license": "ISC", "dependencies": { "agent-base": "^7.1.0", "http-proxy-agent": "^7.0.0", "https-proxy-agent": "^7.0.1", - "lru-cache": "^10.0.1", + "lru-cache": "^11.2.1", "socks-proxy-agent": "^8.0.3" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@npmcli/agent/node_modules/lru-cache": { - "version": "10.4.3", - "license": "ISC" + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } }, "node_modules/@npmcli/fs": { - "version": "4.0.0", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-5.0.0.tgz", + "integrity": "sha512-7OsC1gNORBEawOa5+j2pXN9vsicaIOH5cPXxoR6fJOmH6/EXpJB2CajXOu1fPRFun2m1lktEFX11+P89hqO/og==", "license": "ISC", "dependencies": { "semver": "^7.3.5" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/@oozcitak/dom": { - "version": "1.15.10", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/dom/-/dom-2.0.2.tgz", + "integrity": "sha512-GjpKhkSYC3Mj4+lfwEyI1dqnsKTgwGy48ytZEhm4A/xnH/8z9M3ZVXKr/YGQi3uCLs1AEBS+x5T2JPiueEDW8w==", "license": "MIT", "dependencies": { - "@oozcitak/infra": "1.0.8", - "@oozcitak/url": "1.0.4", - "@oozcitak/util": "8.3.8" + "@oozcitak/infra": "^2.0.2", + "@oozcitak/url": "^3.0.0", + "@oozcitak/util": "^10.0.0" }, "engines": { - "node": ">=8.0" + "node": ">=20.0" } }, "node_modules/@oozcitak/infra": { - "version": "1.0.8", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/@oozcitak/infra/-/infra-2.0.2.tgz", + "integrity": "sha512-2g+E7hoE2dgCz/APPOEK5s3rMhJvNxSMBrP+U+j1OWsIbtSpWxxlUjq1lU8RIsFJNYv7NMlnVsCuHcUzJW+8vA==", "license": "MIT", "dependencies": { - "@oozcitak/util": "8.3.8" + "@oozcitak/util": "^10.0.0" }, "engines": { - "node": ">=6.0" + "node": ">=20.0" } }, "node_modules/@oozcitak/url": { - "version": "1.0.4", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/url/-/url-3.0.0.tgz", + "integrity": "sha512-ZKfET8Ak1wsLAiLWNfFkZc/BraDccuTJKR6svTYc7sVjbR+Iu0vtXdiDMY4o6jaFl5TW2TlS7jbLl4VovtAJWQ==", "license": "MIT", "dependencies": { - "@oozcitak/infra": "1.0.8", - "@oozcitak/util": "8.3.8" + "@oozcitak/infra": "^2.0.2", + "@oozcitak/util": "^10.0.0" }, "engines": { - "node": ">=8.0" + "node": ">=20.0" } }, "node_modules/@oozcitak/util": { - "version": "8.3.8", + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/@oozcitak/util/-/util-10.0.0.tgz", + "integrity": "sha512-hAX0pT/73190NLqBPPWSdBVGtbY6VOhWYK3qqHqtXQ1gK7kS2yz4+ivsN07hpJ6I3aeMtKP6J6npsEKOAzuTLA==", "license": "MIT", "engines": { - "node": ">=8.0" + "node": ">=20.0" } }, "node_modules/@opentelemetry/api": { @@ -7192,7 +7217,9 @@ } }, "node_modules/@redocly/cli": { - "version": "1.34.5", + "version": "1.34.6", + "resolved": "https://registry.npmjs.org/@redocly/cli/-/cli-1.34.6.tgz", + "integrity": "sha512-V03jtLOXLm6+wpTuFNw9+eLHE6R3wywZo4Clt9XMPnulafbJcpCFz+J0e5/4Cw4zZB087xjU7WvRdI/bZ+pHtA==", "dev": true, "license": "MIT", "dependencies": { @@ -7202,8 +7229,8 @@ "@opentelemetry/sdk-trace-node": "1.26.0", "@opentelemetry/semantic-conventions": "1.27.0", "@redocly/config": "^0.22.0", - "@redocly/openapi-core": "1.34.5", - "@redocly/respect-core": "1.34.5", + "@redocly/openapi-core": "1.34.6", + "@redocly/respect-core": "1.34.6", "abort-controller": "^3.0.0", "chokidar": "^3.5.1", "colorette": "^1.2.0", @@ -7215,8 +7242,8 @@ "handlebars": "^4.7.6", "mobx": "^6.0.4", "pluralize": "^8.0.0", - "react": "^17.0.0 || ^18.2.0 || ^19.0.0", - "react-dom": "^17.0.0 || ^18.2.0 || ^19.0.0", + "react": "^17.0.0 || ^18.2.0 || ^19.2.1", + "react-dom": "^17.0.0 || ^18.2.0 || ^19.2.1", "redoc": "2.5.0", "semver": "^7.5.2", "simple-websocket": "^9.0.0", @@ -7415,7 +7442,9 @@ "license": "MIT" }, "node_modules/@redocly/openapi-core": { - "version": "1.34.5", + "version": "1.34.6", + "resolved": "https://registry.npmjs.org/@redocly/openapi-core/-/openapi-core-1.34.6.tgz", + "integrity": "sha512-2+O+riuIUgVSuLl3Lyh5AplWZyVMNuG2F98/o6NrutKJfW4/GTZdPpZlIphS0HGgcOHgmWcCSHj+dWFlZaGSHw==", "dev": true, "license": "MIT", "dependencies": { @@ -7435,13 +7464,15 @@ } }, "node_modules/@redocly/respect-core": { - "version": "1.34.5", + "version": "1.34.6", + "resolved": "https://registry.npmjs.org/@redocly/respect-core/-/respect-core-1.34.6.tgz", + "integrity": "sha512-nXFBRctoB4CPCLR2it2WxDsuAE/nLd4EnW9mQ+IUKrIFAjMv1O6rgggxkgdlyKUyenYkajJIHSKwVbRS6FwlEQ==", "dev": true, "license": "MIT", "dependencies": { "@faker-js/faker": "^7.6.0", "@redocly/ajv": "8.11.2", - "@redocly/openapi-core": "1.34.5", + "@redocly/openapi-core": "1.34.6", "better-ajv-errors": "^1.2.0", "colorette": "^2.0.20", "concat-stream": "^2.0.0", @@ -7466,6 +7497,8 @@ }, "node_modules/@redocly/respect-core/node_modules/@faker-js/faker": { "version": "7.6.0", + "resolved": "https://registry.npmjs.org/@faker-js/faker/-/faker-7.6.0.tgz", + "integrity": "sha512-XK6BTq1NDMo9Xqw/YkYyGjSsg44fbNwYRx7QK2CuoQgyy+f1rrTDHoExVM5PsyXCtfl2vs2vVJ0MN0yN6LppRw==", "dev": true, "license": "MIT", "engines": { @@ -7475,6 +7508,8 @@ }, "node_modules/@redocly/respect-core/node_modules/@redocly/ajv": { "version": "8.11.2", + "resolved": "https://registry.npmjs.org/@redocly/ajv/-/ajv-8.11.2.tgz", + "integrity": "sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==", "dev": true, "license": "MIT", "dependencies": { @@ -7490,11 +7525,15 @@ }, "node_modules/@redocly/respect-core/node_modules/colorette": { "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==", "dev": true, "license": "MIT" }, "node_modules/@redocly/respect-core/node_modules/js-yaml": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", + "integrity": "sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==", "dev": true, "license": "MIT", "dependencies": { @@ -10243,10 +10282,12 @@ "license": "ISC" }, "node_modules/abbrev": { - "version": "3.0.1", + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-4.0.0.tgz", + "integrity": "sha512-a1wflyaL0tHtJSmLSOVybYhy22vRih4eduhhrkcjgrWGnRfrZtovJ2FRjxuTtkkj47O/baf0R86QU5OuYpz8fA==", "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/abort-controller": { @@ -11066,6 +11107,8 @@ }, "node_modules/better-ajv-errors": { "version": "1.2.0", + "resolved": "https://registry.npmjs.org/better-ajv-errors/-/better-ajv-errors-1.2.0.tgz", + "integrity": "sha512-UW+IsFycygIo7bclP9h5ugkNH8EjCSgqyFB/yQ4Hqqa1OEYDtb0uFIkYE0b6+CjkgJYVM5UKI/pJPxjYe9EZlA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -11142,21 +11185,23 @@ } }, "node_modules/body-parser": { - "version": "1.20.3", + "version": "1.20.4", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.4.tgz", + "integrity": "sha512-ZTgYYLMOXY9qKU/57FAo8F+HA2dGX7bqGc71txDRC1rS4frdFI5R7NhluHxH6M0YItAP0sHB4uqAOcYKxO6uGA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", + "bytes": "~3.1.2", "content-type": "~1.0.5", "debug": "2.6.9", "depd": "2.0.0", - "destroy": "1.2.0", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "on-finished": "2.4.1", - "qs": "6.13.0", - "raw-body": "2.5.2", + "destroy": "~1.2.0", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "on-finished": "~2.4.1", + "qs": "~6.14.0", + "raw-body": "~2.5.3", "type-is": "~1.6.18", - "unpipe": "1.0.0" + "unpipe": "~1.0.0" }, "engines": { "node": ">= 0.8", @@ -11170,10 +11215,39 @@ "ms": "2.0.0" } }, + "node_modules/body-parser/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" + }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, "node_modules/body-parser/node_modules/ms": { "version": "2.0.0", "license": "MIT" }, + "node_modules/body-parser/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/brace-expansion": { "version": "2.0.2", "license": "MIT", @@ -11277,6 +11351,8 @@ }, "node_modules/bundle-name": { "version": "4.1.0", + "resolved": "https://registry.npmjs.org/bundle-name/-/bundle-name-4.1.0.tgz", + "integrity": "sha512-tjwM5exMg6BGRI+kNmTntNsvdZS1X8BFYS6tnJ2hdH0kVxM6/eVZ2xy+FqStSWvYmtfFMDLIxurorHwDKfDz5Q==", "dev": true, "license": "MIT", "dependencies": { @@ -11306,83 +11382,63 @@ } }, "node_modules/cacache": { - "version": "19.0.1", + "version": "20.0.3", + "resolved": "https://registry.npmjs.org/cacache/-/cacache-20.0.3.tgz", + "integrity": "sha512-3pUp4e8hv07k1QlijZu6Kn7c9+ZpWWk4j3F8N3xPuCExULobqJydKYOTj1FTq58srkJsXvO7LbGAH4C0ZU3WGw==", "license": "ISC", "dependencies": { - "@npmcli/fs": "^4.0.0", + "@npmcli/fs": "^5.0.0", "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^10.0.1", + "glob": "^13.0.0", + "lru-cache": "^11.1.0", "minipass": "^7.0.3", "minipass-collect": "^2.0.1", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "p-map": "^7.0.2", - "ssri": "^12.0.0", - "tar": "^7.4.3", - "unique-filename": "^4.0.0" + "ssri": "^13.0.0", + "unique-filename": "^5.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/cacache/node_modules/glob": { - "version": "10.5.0", - "license": "ISC", + "version": "13.0.1", + "resolved": "https://registry.npmjs.org/glob/-/glob-13.0.1.tgz", + "integrity": "sha512-B7U/vJpE3DkJ5WXTgTpTRN63uV42DseiXXKMwG14LQBXmsdeIoHAPbU/MEo6II0k5ED74uc2ZGTC6MwHFQhF6w==", + "license": "BlueOak-1.0.0", "dependencies": { - "foreground-child": "^3.1.0", - "jackspeak": "^3.1.2", - "minimatch": "^9.0.4", + "minimatch": "^10.1.2", "minipass": "^7.1.2", - "package-json-from-dist": "^1.0.0", - "path-scurry": "^1.11.1" - }, - "bin": { - "glob": "dist/esm/bin.mjs" + "path-scurry": "^2.0.0" }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/cacache/node_modules/jackspeak": { - "version": "3.4.3", - "license": "BlueOak-1.0.0", - "dependencies": { - "@isaacs/cliui": "^8.0.2" + "engines": { + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" - }, - "optionalDependencies": { - "@pkgjs/parseargs": "^0.11.0" } }, "node_modules/cacache/node_modules/lru-cache": { - "version": "10.4.3", - "license": "ISC" - }, - "node_modules/cacache/node_modules/minimatch": { - "version": "9.0.5", - "license": "ISC", - "dependencies": { - "brace-expansion": "^2.0.1" - }, + "version": "11.2.5", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.2.5.tgz", + "integrity": "sha512-vFrFJkWtJvJnD5hg+hJvVE8Lh/TcMzKnTgCWmtBipwI5yLX/iX+5UB2tfuyODF5E7k9xEzMdYgGqaSb1c0c5Yw==", + "license": "BlueOak-1.0.0", "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "node": "20 || >=22" } }, - "node_modules/cacache/node_modules/path-scurry": { - "version": "1.11.1", + "node_modules/cacache/node_modules/minimatch": { + "version": "10.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.1.2.tgz", + "integrity": "sha512-fu656aJ0n2kcXwsnwnv9g24tkU5uSmOlTjd6WyyaKm2Z+h1qmY6bAjrcaIxF/BslFqbZ8UBtbJi7KgQOZD2PTw==", "license": "BlueOak-1.0.0", "dependencies": { - "lru-cache": "^10.2.0", - "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" + "@isaacs/brace-expansion": "^5.0.1" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": "20 || >=22" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -11566,6 +11622,8 @@ }, "node_modules/chownr": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-3.0.0.tgz", + "integrity": "sha512-+IxzY9BZOQd/XuYPRmrvEVjF/nqj5kgT4kEq7VofrDoM1MxoRjEWkrCC3EtLi59TVawxTAn+orJwFQcrqEN1+g==", "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -12432,7 +12490,8 @@ }, "node_modules/cookie": { "version": "0.7.2", - "dev": true, + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.2.tgz", + "integrity": "sha512-yki5XnKuf750l50uGTllt6kKILY4nQ1eNIQatoXEByZ5dWgnKqbnqmTrBE5B4N7lrMJKQ2ytWMiTO2o0v6Ew/w==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -12940,7 +12999,9 @@ } }, "node_modules/default-browser": { - "version": "5.4.0", + "version": "5.5.0", + "resolved": "https://registry.npmjs.org/default-browser/-/default-browser-5.5.0.tgz", + "integrity": "sha512-H9LMLr5zwIbSxrmvikGuI/5KGhZ8E2zH3stkMgM5LpOWDutGM2JZaj460Udnf1a+946zc7YBgrqEWwbk7zHvGw==", "dev": true, "license": "MIT", "dependencies": { @@ -12956,6 +13017,8 @@ }, "node_modules/default-browser-id": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/default-browser-id/-/default-browser-id-5.0.1.tgz", + "integrity": "sha512-x1VCxdX4t+8wVfd1so/9w+vQ4vx7lKd2Qp5tDRutErwmR85OgmfX7RlLRMWafRMY7hbEiXIbudNrjOAPa/hL8Q==", "dev": true, "license": "MIT", "engines": { @@ -12993,6 +13056,8 @@ }, "node_modules/define-lazy-prop": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/define-lazy-prop/-/define-lazy-prop-3.0.0.tgz", + "integrity": "sha512-N+MeXYoqr3pOgn8xfyRPREN7gHakLYjhsHhWGT3fWAiL4IkAt0iDw14QiiEm2bE30c5XX5q0FtAA3CK5f9/BUg==", "dev": true, "license": "MIT", "engines": { @@ -13082,7 +13147,9 @@ } }, "node_modules/diff": { - "version": "4.0.2", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/diff/-/diff-4.0.4.tgz", + "integrity": "sha512-X07nttJQkwkfKfvTPG/KSnE2OMdcUCao6+eXF3wmnIQRn2aPAHH3VxDbDOdegkd6JbPsXqShpvEOHfAT+nCNwQ==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -13139,6 +13206,8 @@ }, "node_modules/dotenv": { "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", "dev": true, "license": "BSD-2-Clause", "engines": { @@ -13315,6 +13384,8 @@ }, "node_modules/env-paths": { "version": "2.2.1", + "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", + "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", "license": "MIT", "engines": { "node": ">=6" @@ -13336,6 +13407,8 @@ }, "node_modules/err-code": { "version": "2.0.3", + "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", + "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", "license": "MIT" }, "node_modules/error-ex": { @@ -14491,6 +14564,7 @@ }, "node_modules/esprima": { "version": "4.0.1", + "dev": true, "license": "BSD-2-Clause", "bin": { "esparse": "bin/esparse.js", @@ -14635,40 +14709,44 @@ }, "node_modules/exponential-backoff": { "version": "3.1.3", + "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.3.tgz", + "integrity": "sha512-ZgEeZXj30q+I0EN+CbSSpIyPaJ5HVQD18Z1m+u1FXbAeT94mr1zw50q4q6jiiC447Nl/YTcIYSAftiGqetwXCA==", "license": "Apache-2.0" }, "node_modules/express": { - "version": "4.21.2", + "version": "4.22.1", + "resolved": "https://registry.npmjs.org/express/-/express-4.22.1.tgz", + "integrity": "sha512-F2X8g9P1X7uCPZMA3MVf9wcTqlyNp7IhH5qPCI0izhaOIYXaW9L535tGA3qmjRzpH+bZczqq7hVKxTR4NWnu+g==", "license": "MIT", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.3", - "content-disposition": "0.5.4", + "body-parser": "~1.20.3", + "content-disposition": "~0.5.4", "content-type": "~1.0.4", - "cookie": "0.7.1", - "cookie-signature": "1.0.6", + "cookie": "~0.7.1", + "cookie-signature": "~1.0.6", "debug": "2.6.9", "depd": "2.0.0", "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.3.1", - "fresh": "0.5.2", - "http-errors": "2.0.0", + "finalhandler": "~1.3.1", + "fresh": "~0.5.2", + "http-errors": "~2.0.0", "merge-descriptors": "1.0.3", "methods": "~1.1.2", - "on-finished": "2.4.1", + "on-finished": "~2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.12", + "path-to-regexp": "~0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.13.0", + "qs": "~6.14.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.19.0", - "serve-static": "1.16.2", + "send": "~0.19.0", + "serve-static": "~1.16.2", "setprototypeof": "1.2.0", - "statuses": "2.0.1", + "statuses": "~2.0.1", "type-is": "~1.6.18", "utils-merge": "1.0.1", "vary": "~1.1.2" @@ -14712,19 +14790,6 @@ "url": "https://opencollective.com/express" } }, - "node_modules/express-openapi-validator/node_modules/qs": { - "version": "6.14.0", - "license": "BSD-3-Clause", - "dependencies": { - "side-channel": "^1.1.0" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/express-prom-bundle": { "version": "6.6.0", "license": "MIT", @@ -14739,13 +14804,6 @@ "prom-client": ">=12.0.0" } }, - "node_modules/express/node_modules/cookie": { - "version": "0.7.1", - "license": "MIT", - "engines": { - "node": ">= 0.6" - } - }, "node_modules/express/node_modules/debug": { "version": "2.6.9", "license": "MIT", @@ -15181,6 +15239,8 @@ }, "node_modules/fs-minipass": { "version": "3.0.3", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", + "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", "license": "ISC", "dependencies": { "minipass": "^7.0.3" @@ -15193,6 +15253,21 @@ "version": "1.0.0", "license": "ISC" }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, "node_modules/function-bind": { "version": "1.1.2", "license": "MIT", @@ -15253,7 +15328,9 @@ } }, "node_modules/gdal-async": { - "version": "3.11.5", + "version": "3.12.1", + "resolved": "https://registry.npmjs.org/gdal-async/-/gdal-async-3.12.1.tgz", + "integrity": "sha512-KmxY5Vk571aBcoxzbSwPnXcJvYfzyEIiMMADpOuZlnXp4v7LaTlOYSqXvdzyH3xwcO8N/+265rTFv7ArPJL8Fg==", "bundleDependencies": [ "@mapbox/node-pre-gyp" ], @@ -15262,9 +15339,9 @@ "dependencies": { "@mapbox/node-pre-gyp": "^2.0.0", "@petamoriken/float16": "^3.9.2", - "nan": "^2.17.0", - "node-gyp": "^11.0.0", - "xmlbuilder2": "^3.0.2", + "nan": "^2.23.0", + "node-gyp": "^12.1.0", + "xmlbuilder2": "^4.0.0", "yatag": "^1.2.0" }, "engines": { @@ -16113,6 +16190,8 @@ }, "node_modules/http-cache-semantics": { "version": "4.2.0", + "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.2.0.tgz", + "integrity": "sha512-dTxcvPXqPvXBQpq5dUr6mEMJX4oIEFv6bwom3FDwKRDsuIjjJGANqhBuoAn9c1RQJIdAKav33ED65E2ys+87QQ==", "license": "BSD-2-Clause" }, "node_modules/http-errors": { @@ -16131,6 +16210,8 @@ }, "node_modules/http-proxy-agent": { "version": "7.0.2", + "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-7.0.2.tgz", + "integrity": "sha512-T1gkAiYYDWYx3V5Bmyu7HcfcvL7mUrTWiM6yOfa3PIphViJ/gFPbvidQ+veqSOHci/PxBcDabeUNCzpOODJZig==", "license": "MIT", "dependencies": { "agent-base": "^7.1.0", @@ -16339,6 +16420,8 @@ }, "node_modules/ip-address": { "version": "10.1.0", + "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-10.1.0.tgz", + "integrity": "sha512-XXADHxXmvT9+CRxhXg56LJovE+bmWnEWB78LB83VZTprKTmaC5QfruXocxzTZ2Kl0DNwKuBdlIhjL8LeY8Sf8Q==", "license": "MIT", "engines": { "node": ">= 12" @@ -16499,6 +16582,8 @@ }, "node_modules/is-docker": { "version": "3.0.0", + "resolved": "https://registry.npmjs.org/is-docker/-/is-docker-3.0.0.tgz", + "integrity": "sha512-eljcgEDlEns/7AXFosB5K/2nCM4P7FQPkGc/DWLy5rmFEWvZayGrik1d9/QIY5nJ4f9YsVvBkA6kJpHn9rISdQ==", "dev": true, "license": "MIT", "bin": { @@ -16577,6 +16662,8 @@ }, "node_modules/is-inside-container": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/is-inside-container/-/is-inside-container-1.0.0.tgz", + "integrity": "sha512-KIYLCCJghfHZxqjYBE7rEy0OBuTd5xCHS7tHVgvCLkx7StIoaxwNW3hCALgEUjFfeRk+MG/Qxmp/vtETEF3tRA==", "dev": true, "license": "MIT", "dependencies": { @@ -16848,6 +16935,8 @@ }, "node_modules/is-wsl": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/is-wsl/-/is-wsl-3.1.0.tgz", + "integrity": "sha512-UcVfVfaK4Sc4m7X3dUSoHoozQGBEFeDC+zVo06t98xe8CzHSZZBekNXH+tu0NalHolcJ/QAGqS46Hef7QXBIMw==", "dev": true, "license": "MIT", "dependencies": { @@ -17790,6 +17879,8 @@ }, "node_modules/jsep": { "version": "1.4.0", + "resolved": "https://registry.npmjs.org/jsep/-/jsep-1.4.0.tgz", + "integrity": "sha512-B7qPcEVE3NVkmSJbaYxvv4cHkVW7DQsZz13pUMrfS8z8Q/BuShN+gcTXrUlPiGqM2/t/EEaI030bpxMqY8gMlw==", "dev": true, "license": "MIT", "engines": { @@ -17878,6 +17969,8 @@ }, "node_modules/jsonpath-plus": { "version": "10.3.0", + "resolved": "https://registry.npmjs.org/jsonpath-plus/-/jsonpath-plus-10.3.0.tgz", + "integrity": "sha512-8TNmfeTCk2Le33A3vRRwtuworG/L5RrgMvdjhKZxvyShO+mBu2fP50OWUjRLNtvw344DdDarFh9buFAZs5ujeA==", "dev": true, "license": "MIT", "dependencies": { @@ -17895,6 +17988,8 @@ }, "node_modules/jsonpointer": { "version": "5.0.1", + "resolved": "https://registry.npmjs.org/jsonpointer/-/jsonpointer-5.0.1.tgz", + "integrity": "sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==", "dev": true, "license": "MIT", "engines": { @@ -18247,27 +18342,31 @@ "license": "ISC" }, "node_modules/make-fetch-happen": { - "version": "14.0.3", + "version": "15.0.3", + "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-15.0.3.tgz", + "integrity": "sha512-iyyEpDty1mwW3dGlYXAJqC/azFn5PPvgKVwXayOGBSmKLxhKZ9fg4qIan2ePpp1vJIwfFiO34LAPZgq9SZW9Aw==", "license": "ISC", "dependencies": { - "@npmcli/agent": "^3.0.0", - "cacache": "^19.0.1", + "@npmcli/agent": "^4.0.0", + "cacache": "^20.0.1", "http-cache-semantics": "^4.1.1", "minipass": "^7.0.2", - "minipass-fetch": "^4.0.0", + "minipass-fetch": "^5.0.0", "minipass-flush": "^1.0.5", "minipass-pipeline": "^1.2.4", "negotiator": "^1.0.0", - "proc-log": "^5.0.0", + "proc-log": "^6.0.0", "promise-retry": "^2.0.1", - "ssri": "^12.0.0" + "ssri": "^13.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/make-fetch-happen/node_modules/negotiator": { "version": "1.0.0", + "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-1.0.0.tgz", + "integrity": "sha512-8Ofs/AUQh8MaEcrlq5xOX0CQ9ypTF5dl78mjlMNfOK08fzpgTHQRQPBxcPlEtIw0yRpws+Zo/3r+5WRby7u3Gg==", "license": "MIT", "engines": { "node": ">= 0.6" @@ -18493,6 +18592,8 @@ }, "node_modules/minipass-collect": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-2.0.1.tgz", + "integrity": "sha512-D7V8PO9oaz7PWGLbCACuI1qEOsq7UKfLotx/C0Aet43fCUB/wfQ7DYeq2oR/svFJGYDHPr38SHATeaj/ZoKHKw==", "license": "ISC", "dependencies": { "minipass": "^7.0.3" @@ -18502,15 +18603,17 @@ } }, "node_modules/minipass-fetch": { - "version": "4.0.1", + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-5.0.1.tgz", + "integrity": "sha512-yHK8pb0iCGat0lDrs/D6RZmCdaBT64tULXjdxjSMAqoDi18Q3qKEUTHypHQZQd9+FYpIS+lkvpq6C/R6SbUeRw==", "license": "MIT", "dependencies": { "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", + "minipass-sized": "^2.0.0", "minizlib": "^3.0.1" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" }, "optionalDependencies": { "encoding": "^0.1.13" @@ -18518,6 +18621,8 @@ }, "node_modules/minipass-flush": { "version": "1.0.5", + "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", + "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", "license": "ISC", "dependencies": { "minipass": "^3.0.0" @@ -18528,6 +18633,8 @@ }, "node_modules/minipass-flush/node_modules/minipass": { "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -18538,10 +18645,14 @@ }, "node_modules/minipass-flush/node_modules/yallist": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, "node_modules/minipass-pipeline": { "version": "1.2.4", + "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", + "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", "license": "ISC", "dependencies": { "minipass": "^3.0.0" @@ -18552,6 +18663,8 @@ }, "node_modules/minipass-pipeline/node_modules/minipass": { "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", "license": "ISC", "dependencies": { "yallist": "^4.0.0" @@ -18562,34 +18675,26 @@ }, "node_modules/minipass-pipeline/node_modules/yallist": { "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", "license": "ISC" }, "node_modules/minipass-sized": { - "version": "1.0.3", - "license": "ISC", - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-2.0.0.tgz", + "integrity": "sha512-zSsHhto5BcUVM2m1LurnXY6M//cGhVaegT71OfOXoprxT6o780GZd792ea6FfrQkuU4usHZIUczAQMRUE2plzA==", "license": "ISC", "dependencies": { - "yallist": "^4.0.0" + "minipass": "^7.1.2" }, "engines": { "node": ">=8" } }, - "node_modules/minipass-sized/node_modules/yallist": { - "version": "4.0.0", - "license": "ISC" - }, "node_modules/minizlib": { "version": "3.1.0", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-3.1.0.tgz", + "integrity": "sha512-KZxYo1BUkWD2TVFLr0MQoM8vUUigWD3LlD83a/75BqC+4qE0Hb1Vo5v1FgcfaNXvfXzr+5EhQ6ing/CaBijTlw==", "license": "MIT", "dependencies": { "minipass": "^7.1.2" @@ -18829,36 +18934,42 @@ } }, "node_modules/node-gyp": { - "version": "11.5.0", + "version": "12.2.0", + "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-12.2.0.tgz", + "integrity": "sha512-q23WdzrQv48KozXlr0U1v9dwO/k59NHeSzn6loGcasyf0UnSrtzs8kRxM+mfwJSf0DkX0s43hcqgnSO4/VNthQ==", "license": "MIT", "dependencies": { "env-paths": "^2.2.0", "exponential-backoff": "^3.1.1", "graceful-fs": "^4.2.6", - "make-fetch-happen": "^14.0.3", - "nopt": "^8.0.0", - "proc-log": "^5.0.0", + "make-fetch-happen": "^15.0.0", + "nopt": "^9.0.0", + "proc-log": "^6.0.0", "semver": "^7.3.5", - "tar": "^7.4.3", + "tar": "^7.5.4", "tinyglobby": "^0.2.12", - "which": "^5.0.0" + "which": "^6.0.0" }, "bin": { "node-gyp": "bin/node-gyp.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/node-gyp/node_modules/isexe": { "version": "3.1.1", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-3.1.1.tgz", + "integrity": "sha512-LpB/54B+/2J5hqQ7imZHfdU31OlgQqx7ZicVlkm9kzg9/w8GKLEcFfJl/t7DCEDueOyBAD6zCCwTO6Fzs0NoEQ==", "license": "ISC", "engines": { "node": ">=16" } }, "node_modules/node-gyp/node_modules/which": { - "version": "5.0.0", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/which/-/which-6.0.0.tgz", + "integrity": "sha512-f+gEpIKMR9faW/JgAgPK1D7mekkFoqbmiwvNzuhsHetni20QSgzg9Vhn0g2JSJkkfehQnqdUAx7/e15qS1lPxg==", "license": "ISC", "dependencies": { "isexe": "^3.1.1" @@ -18867,7 +18978,7 @@ "node-which": "bin/which.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/node-int64": { @@ -18915,16 +19026,18 @@ "license": "MIT" }, "node_modules/nopt": { - "version": "8.1.0", + "version": "9.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-9.0.0.tgz", + "integrity": "sha512-Zhq3a+yFKrYwSBluL4H9XP3m3y5uvQkB/09CwDruCiRmR/UJYnn9W4R48ry0uGC70aeTPKLynBtscP9efFFcPw==", "license": "ISC", "dependencies": { - "abbrev": "^3.0.0" + "abbrev": "^4.0.0" }, "bin": { "nopt": "bin/nopt.js" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/normalize-package-data": { @@ -19195,6 +19308,8 @@ }, "node_modules/open": { "version": "10.2.0", + "resolved": "https://registry.npmjs.org/open/-/open-10.2.0.tgz", + "integrity": "sha512-YgBpdJHPyQ2UE5x+hlSXcnejzAvD0b22U2OuAP+8OnlJT+PjWPxtgmGqKKc+RgTM63U9gN0YzrYc71R2WT/hTA==", "dev": true, "license": "MIT", "dependencies": { @@ -19357,6 +19472,8 @@ }, "node_modules/outdent": { "version": "0.8.0", + "resolved": "https://registry.npmjs.org/outdent/-/outdent-0.8.0.tgz", + "integrity": "sha512-KiOAIsdpUTcAXuykya5fnVVT+/5uS0Q1mrkRHcF89tpieSmY33O/tmc54CqwA+bfhbtEfZUNLHaPUiB9X3jt1A==", "dev": true, "license": "MIT" }, @@ -19404,6 +19521,8 @@ }, "node_modules/p-map": { "version": "7.0.4", + "resolved": "https://registry.npmjs.org/p-map/-/p-map-7.0.4.tgz", + "integrity": "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ==", "license": "MIT", "engines": { "node": ">=18" @@ -20086,10 +20205,12 @@ } }, "node_modules/proc-log": { - "version": "5.0.0", + "version": "6.1.0", + "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-6.1.0.tgz", + "integrity": "sha512-iG+GYldRf2BQ0UDUAd6JQ/RwzaQy6mXmsk/IzlYyal4A4SNFw54MeH4/tLkF4I5WoWG9SQwuqWzS99jaFQHBuQ==", "license": "ISC", "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/process": { @@ -20120,6 +20241,8 @@ }, "node_modules/promise-retry": { "version": "2.0.1", + "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", + "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", "license": "MIT", "dependencies": { "err-code": "^2.0.2", @@ -20241,10 +20364,12 @@ } }, "node_modules/qs": { - "version": "6.13.0", + "version": "6.14.1", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.14.1.tgz", + "integrity": "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ==", "license": "BSD-3-Clause", "dependencies": { - "side-channel": "^1.0.6" + "side-channel": "^1.1.0" }, "engines": { "node": ">=0.6" @@ -20314,14 +20439,45 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", + "version": "2.5.3", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.3.tgz", + "integrity": "sha512-s4VSOf6yN0rvbRZGxs8Om5CWj6seneMwK3oDb4lWDH0UPhWcxwOWw5+qk24bxq87szX1ydrwylIOp2uG1ojUpA==", "license": "MIT", "dependencies": { - "bytes": "3.1.2", - "http-errors": "2.0.0", - "iconv-lite": "0.4.24", - "unpipe": "1.0.0" + "bytes": "~3.1.2", + "http-errors": "~2.0.1", + "iconv-lite": "~0.4.24", + "unpipe": "~1.0.0" + }, + "engines": { + "node": ">= 0.8" + } + }, + "node_modules/raw-body/node_modules/http-errors": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", + "integrity": "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==", + "license": "MIT", + "dependencies": { + "depd": "~2.0.0", + "inherits": "~2.0.4", + "setprototypeof": "~1.2.0", + "statuses": "~2.0.2", + "toidentifier": "~1.0.1" }, + "engines": { + "node": ">= 0.8" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/raw-body/node_modules/statuses": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.2.tgz", + "integrity": "sha512-DvEy55V3DB7uknRo+4iOGT5fP1slR8wQohVdknigZPMpMstaKJQWhwiYBACJE3Ul2pTnATihhBYnRhZQHGBiRw==", + "license": "MIT", "engines": { "node": ">= 0.8" } @@ -20354,7 +20510,9 @@ } }, "node_modules/react": { - "version": "19.2.0", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", + "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "dev": true, "license": "MIT", "engines": { @@ -20362,14 +20520,16 @@ } }, "node_modules/react-dom": { - "version": "19.2.0", + "version": "19.2.4", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", + "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "dev": true, "license": "MIT", "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { - "react": "^19.2.0" + "react": "^19.2.4" } }, "node_modules/react-is": { @@ -20822,6 +20982,8 @@ }, "node_modules/retry": { "version": "0.12.0", + "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", + "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", "license": "MIT", "engines": { "node": ">= 4" @@ -20922,6 +21084,8 @@ }, "node_modules/run-applescript": { "version": "7.1.0", + "resolved": "https://registry.npmjs.org/run-applescript/-/run-applescript-7.1.0.tgz", + "integrity": "sha512-DPe5pVFaAsinSaV6QjQ6gdiedWDcRCbUuiQfQa2wmWV7+xC9bGulGI8+TdRmoFkAPaBXk8CrAbnlY2ISniJ47Q==", "dev": true, "license": "MIT", "engines": { @@ -21142,6 +21306,8 @@ }, "node_modules/set-cookie-parser": { "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", "dev": true, "license": "MIT" }, @@ -21460,6 +21626,8 @@ }, "node_modules/smart-buffer": { "version": "4.2.0", + "resolved": "https://registry.npmjs.org/smart-buffer/-/smart-buffer-4.2.0.tgz", + "integrity": "sha512-94hK0Hh8rPqQl2xXc3HsaBoOXKV20MToPkcXvwbISWLEs+64sBq5kFgn2kJDHb1Pry9yrP0dxrCI9RRci7RXKg==", "license": "MIT", "engines": { "node": ">= 6.0.0", @@ -21468,6 +21636,8 @@ }, "node_modules/socks": { "version": "2.8.7", + "resolved": "https://registry.npmjs.org/socks/-/socks-2.8.7.tgz", + "integrity": "sha512-HLpt+uLy/pxB+bum/9DzAgiKS8CX1EvbWxI4zlmgGCExImLdiad2iCwXT5Z4c9c3Eq8rP2318mPW2c+QbtjK8A==", "license": "MIT", "dependencies": { "ip-address": "^10.0.1", @@ -21480,6 +21650,8 @@ }, "node_modules/socks-proxy-agent": { "version": "8.0.5", + "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-8.0.5.tgz", + "integrity": "sha512-HehCEsotFqbPW9sJ8WVYB6UbmIMv7kUUORIF2Nncq4VQvBfNBLibW9YZR5dlYCSUhwcD628pRllm7n+E+YTzJw==", "license": "MIT", "dependencies": { "agent-base": "^7.1.2", @@ -21587,16 +21759,19 @@ }, "node_modules/sprintf-js": { "version": "1.0.3", + "dev": true, "license": "BSD-3-Clause" }, "node_modules/ssri": { - "version": "12.0.0", + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/ssri/-/ssri-13.0.0.tgz", + "integrity": "sha512-yizwGBpbCn4YomB2lzhZqrHLJoqFGXihNbib3ozhqF/cIp5ue+xSmOQrjNasEE62hFxsCcg/V/z23t4n8jMEng==", "license": "ISC", "dependencies": { "minipass": "^7.0.3" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/stack-utils": { @@ -22177,7 +22352,9 @@ } }, "node_modules/tar": { - "version": "7.5.2", + "version": "7.5.7", + "resolved": "https://registry.npmjs.org/tar/-/tar-7.5.7.tgz", + "integrity": "sha512-fov56fJiRuThVFXD6o6/Q354S7pnWMJIVlDBYijsTNx6jKSE4pvrDTs6lUnmGvNyfJwFQQwWy3owKz1ucIhveQ==", "license": "BlueOak-1.0.0", "dependencies": { "@isaacs/fs-minipass": "^4.0.0", @@ -22241,6 +22418,8 @@ }, "node_modules/tar/node_modules/yallist": { "version": "5.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-5.0.0.tgz", + "integrity": "sha512-YgvUTfwqyc7UXVMrB+SImsVYSmTS8X/tSrtdNZMImM+n7+QTriRXyXim0mBrTXNeqzVF0KWGgHPeiyViFFrNDw==", "license": "BlueOak-1.0.0", "engines": { "node": ">=18" @@ -22377,6 +22556,8 @@ }, "node_modules/tinyglobby": { "version": "0.2.15", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.15.tgz", + "integrity": "sha512-j2Zq4NyQYG5XMST4cbs02Ak8iJUdxRM0XI5QyxXuZOzKOINmWurp3smXu3y5wDcJrptwpSjgXHzIQxR0omXljQ==", "license": "MIT", "dependencies": { "fdir": "^6.5.0", @@ -22391,6 +22572,8 @@ }, "node_modules/tinyglobby/node_modules/fdir": { "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", "license": "MIT", "engines": { "node": ">=12.0.0" @@ -22406,6 +22589,8 @@ }, "node_modules/tinyglobby/node_modules/picomatch": { "version": "4.0.3", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", + "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "license": "MIT", "engines": { "node": ">=12" @@ -22888,7 +23073,9 @@ } }, "node_modules/undici": { - "version": "6.22.0", + "version": "6.23.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.23.0.tgz", + "integrity": "sha512-VfQPToRA5FZs/qJxLIinmU59u0r7LXqoJkCzinq3ckNJp3vKEh7jTWN589YQ5+aoAC/TGRLyJLCPKcLQbM8r9g==", "dev": true, "license": "MIT", "engines": { @@ -22932,23 +23119,27 @@ } }, "node_modules/unique-filename": { - "version": "4.0.0", + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-5.0.0.tgz", + "integrity": "sha512-2RaJTAvAb4owyjllTfXzFClJ7WsGxlykkPvCr9pA//LD9goVq+m4PPAeBgNodGZ7nSrntT/auWpJ6Y5IFXcfjg==", "license": "ISC", "dependencies": { - "unique-slug": "^5.0.0" + "unique-slug": "^6.0.0" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/unique-slug": { - "version": "5.0.0", + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-6.0.0.tgz", + "integrity": "sha512-4Lup7Ezn8W3d52/xBhZBVdx323ckxa7DEvd9kPQHppTkLoJXw6ltrBCyj5pnrxj0qKDxYMJ56CoxNuFCscdTiw==", "license": "ISC", "dependencies": { "imurmurhash": "^0.1.4" }, "engines": { - "node": "^18.17.0 || >=20.5.0" + "node": "^20.17.0 || >=22.9.0" } }, "node_modules/universalify": { @@ -23012,6 +23203,8 @@ }, "node_modules/uri-js-replace": { "version": "1.0.1", + "resolved": "https://registry.npmjs.org/uri-js-replace/-/uri-js-replace-1.0.1.tgz", + "integrity": "sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==", "dev": true, "license": "MIT" }, @@ -23293,6 +23486,8 @@ }, "node_modules/wsl-utils": { "version": "0.1.0", + "resolved": "https://registry.npmjs.org/wsl-utils/-/wsl-utils-0.1.0.tgz", + "integrity": "sha512-h3Fbisa2nKGPxCpm89Hk33lBLsnaGBvctQopaBSOW/uIs6FTe1ATyAnKFJrzVs9vpGdsTe73WF3V4lIsk4Gacw==", "dev": true, "license": "MIT", "dependencies": { @@ -23306,34 +23501,18 @@ } }, "node_modules/xmlbuilder2": { - "version": "3.1.1", + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/xmlbuilder2/-/xmlbuilder2-4.0.3.tgz", + "integrity": "sha512-bx8Q1STctnNaaDymWnkfQLKofs0mGNN7rLLapJlGuV3VlvegD7Ls4ggMjE3aUSWItCCzU0PEv45lI87iSigiCA==", "license": "MIT", "dependencies": { - "@oozcitak/dom": "1.15.10", - "@oozcitak/infra": "1.0.8", - "@oozcitak/util": "8.3.8", - "js-yaml": "3.14.1" + "@oozcitak/dom": "^2.0.2", + "@oozcitak/infra": "^2.0.2", + "@oozcitak/util": "^10.0.0", + "js-yaml": "^4.1.1" }, "engines": { - "node": ">=12.0" - } - }, - "node_modules/xmlbuilder2/node_modules/argparse": { - "version": "1.0.10", - "license": "MIT", - "dependencies": { - "sprintf-js": "~1.0.2" - } - }, - "node_modules/xmlbuilder2/node_modules/js-yaml": { - "version": "3.14.1", - "license": "MIT", - "dependencies": { - "argparse": "^1.0.7", - "esprima": "^4.0.0" - }, - "bin": { - "js-yaml": "bin/js-yaml.js" + "node": ">=20.0" } }, "node_modules/xtend": { @@ -23486,4 +23665,4 @@ } } } -} \ No newline at end of file +} diff --git a/src/info/controllers/infoController.ts b/src/info/controllers/infoController.ts index 88f0d85c..186bff67 100644 --- a/src/info/controllers/infoController.ts +++ b/src/info/controllers/infoController.ts @@ -1,8 +1,6 @@ import { RequestHandler } from 'express'; -import { HttpError } from 'express-openapi-validator/dist/framework/types'; import { StatusCodes } from 'http-status-codes'; import { inject, injectable } from 'tsyringe'; -import { FileNotFoundError, GdalInfoError } from '../../ingestion/errors/ingestionErrors'; import { InfoData } from '../../ingestion/schemas/infoDataSchema'; import { GpkgInputFiles, INGESTION_SCHEMAS_VALIDATOR_SYMBOL, SchemasValidator } from '../../utils/validation/schemasValidator'; import { InfoManager } from '../models/infoManager'; @@ -23,14 +21,6 @@ export class InfoController { res.status(StatusCodes.OK).send(filesGdalInfoData); } catch (err) { - if (err instanceof FileNotFoundError) { - (err as HttpError).status = StatusCodes.NOT_FOUND; - } - - if (err instanceof GdalInfoError) { - (err as HttpError).status = StatusCodes.UNPROCESSABLE_ENTITY; - } - next(err); } }; diff --git a/src/ingestion/controllers/ingestionController.ts b/src/ingestion/controllers/ingestionController.ts index 821cb256..9a725460 100644 --- a/src/ingestion/controllers/ingestionController.ts +++ b/src/ingestion/controllers/ingestionController.ts @@ -6,11 +6,12 @@ import { inject, injectable } from 'tsyringe'; import { GpkgError } from '../../serviceClients/database/errors'; import { INGESTION_SCHEMAS_VALIDATOR_SYMBOL, SchemasValidator } from '../../utils/validation/schemasValidator'; import { FileNotFoundError, UnsupportedEntityError, ValidationError } from '../errors/ingestionErrors'; -import type { IRetryRequestParams, IRecordRequestParams, ResponseId } from '../interfaces'; +import type { IRetryRequestParams, IRecordRequestParams, IAbortRequestParams, ResponseId } from '../interfaces'; import { IngestionManager } from '../models/ingestionManager'; type NewLayerHandler = RequestHandler; type RetryIngestionHandler = RequestHandler; +type AbortIngestionHandler = RequestHandler; type UpdateLayerHandler = RequestHandler; @injectable() @@ -84,4 +85,26 @@ export class IngestionController { next(error); } }; + + public abortIngestion: AbortIngestionHandler = async (req, res, next) => { + try { + await this.ingestionManager.abortIngestion(req.params.jobId); + + res.status(StatusCodes.OK).send(); + } catch (error) { + if (error instanceof ValidationError) { + (error as HttpError).status = StatusCodes.BAD_REQUEST; //400 + } + if (error instanceof NotFoundError) { + (error as HttpError).status = StatusCodes.NOT_FOUND; //404 + } + if (error instanceof ConflictError) { + (error as HttpError).status = StatusCodes.CONFLICT; //409 + } + if (error instanceof UnsupportedEntityError) { + (error as HttpError).status = StatusCodes.UNPROCESSABLE_ENTITY; //422 + } + next(error); + } + }; } diff --git a/src/ingestion/errors/ingestionErrors.ts b/src/ingestion/errors/ingestionErrors.ts index 048013ab..1cd566b8 100644 --- a/src/ingestion/errors/ingestionErrors.ts +++ b/src/ingestion/errors/ingestionErrors.ts @@ -1,6 +1,6 @@ import { OperationStatus } from '@map-colonies/mc-priority-queue'; import { Logger } from '@map-colonies/js-logger'; -import { BadRequestError, NotFoundError } from '@map-colonies/error-types'; +import { ConflictError, NotFoundError } from '@map-colonies/error-types'; import { Span } from '@opentelemetry/api'; export class UnsupportedEntityError extends Error { @@ -43,13 +43,12 @@ export class ValidationError extends Error { } } -export function throwInvalidJobStatusError(jobId: string, currentStatus: OperationStatus, logger: Logger, span?: Span): never { - const validStatuses = [OperationStatus.FAILED, OperationStatus.SUSPENDED]; - const message = `Cannot retry job with id: ${jobId} because its status is: ${currentStatus}. Expected status: ${validStatuses.join(' or ')}`; +export function throwInvalidJobStatusError(operation: string, jobId: string, currentStatus: OperationStatus, logger: Logger, span?: Span): never { + const message = `Cannot ${operation} job with id: ${jobId} because its status is: ${currentStatus}`; - logger.error({ msg: message, jobId, currentStatus, validStatuses }); + logger.error({ msg: message, jobId, currentStatus }); - const error = new BadRequestError(message); + const error = new ConflictError(message); span?.setAttribute('exception.type', error.status); throw error; } diff --git a/src/ingestion/interfaces.ts b/src/ingestion/interfaces.ts index 67e086ac..7d706b2e 100644 --- a/src/ingestion/interfaces.ts +++ b/src/ingestion/interfaces.ts @@ -21,6 +21,15 @@ export interface IRetryRequestParams { jobId: string; } +export interface IAbortRequestParams { + jobId: string; +} + +export enum IngestionOperation { + RETRY = 'retry', + ABORT = 'abort', +} + export interface PixelRange { min: number; max: number; diff --git a/src/ingestion/models/ingestionManager.ts b/src/ingestion/models/ingestionManager.ts index 36e2fcd3..80f1ec01 100644 --- a/src/ingestion/models/ingestionManager.ts +++ b/src/ingestion/models/ingestionManager.ts @@ -39,6 +39,7 @@ import { getShapefileFiles } from '../../utils/shapefile'; import { ZodValidator } from '../../utils/validation/zodValidator'; import { ValidateManager } from '../../validate/models/validateManager'; import { ChecksumError, throwInvalidJobStatusError } from '../errors/ingestionErrors'; +import { IngestionOperation } from '../interfaces'; import type { IngestionBaseJobParams, ResponseId } from '../interfaces'; import type { RasterLayerMetadata } from '../schemas/layerCatalogSchema'; import type { IngestionNewLayer } from '../schemas/newLayerSchema'; @@ -68,6 +69,7 @@ export class IngestionManager { private readonly updateJobType: string; private readonly swapUpdateJobType: string; private readonly validationTaskType: string; + private readonly finalizeTaskType: string; private readonly sourceMount: string; private readonly jobTrackerServiceUrl: string; @@ -97,6 +99,7 @@ export class IngestionManager { this.updateJobType = config.get('jobManager.ingestionUpdateJobType'); this.swapUpdateJobType = config.get('jobManager.ingestionSwapUpdateJobType'); this.validationTaskType = config.get('jobManager.validationTaskType'); + this.finalizeTaskType = config.get('jobManager.finalizeTaskType'); this.sourceMount = config.get('storageExplorer.layerSourceDir'); this.jobTrackerServiceUrl = config.get('services.jobTrackerServiceURL'); } @@ -206,13 +209,13 @@ export class IngestionManager { const retryJob: IJobResponse = await this.jobManagerWrapper.getJob(jobId); if (!this.isJobRetryable(retryJob.status)) { - throwInvalidJobStatusError(jobId, retryJob.status, this.logger, activeSpan); + throwInvalidJobStatusError(IngestionOperation.RETRY, jobId, retryJob.status, this.logger, activeSpan); } const validationTask: ITaskResponse = await this.getValidationTask(jobId, logCtx); const { resourceId, productType } = this.parseAndValidateJobIdentifiers(retryJob.resourceId, retryJob.productType); await this.zodValidator.validate(ingestionValidationTaskParamsSchema, validationTask.parameters); - + if (validationTask.parameters.isValid === true) { await this.softReset(jobId, logCtx); } else { @@ -682,6 +685,44 @@ export class IngestionManager { return validStatuses.includes(status); } + @withSpanV4 + private isJobAbortable(status: OperationStatus): boolean { + const invalidStatuses = [OperationStatus.COMPLETED, OperationStatus.ABORTED]; + return !invalidStatuses.includes(status); + } + + public async abortIngestion(jobId: string): Promise { + const logCtx: LogContext = { ...this.logContext, function: this.abortIngestion.name }; + const activeSpan = trace.getActiveSpan(); + activeSpan?.updateName('ingestionManager.abortIngestion'); + + this.logger.info({ msg: 'starting abort ingestion process', logContext: logCtx, jobId }); + + const job: IJobResponse = await this.jobManagerWrapper.getJob(jobId); + + if (!this.isJobAbortable(job.status)) { + throwInvalidJobStatusError(IngestionOperation.ABORT, jobId, job.status, this.logger, activeSpan); + } + + const tasks = await this.jobManagerWrapper.getTasksForJob(jobId); + const hasFinalize = tasks.some((task) => task.type === this.finalizeTaskType); + + if (hasFinalize) { + const errorMessage = `cannot abort job ${jobId} - job already in finalization stage and cannot be aborted`; + this.logger.error({ msg: errorMessage, logContext: logCtx, jobId }); + activeSpan?.setStatus({ code: SpanStatusCode.ERROR, message: errorMessage }); + throw new ConflictError(errorMessage); + } + + this.logger.info({ msg: 'aborting job', logContext: logCtx, jobId }); + await this.jobManagerWrapper.abortJob(jobId); + + const { resourceId, productType } = this.parseAndValidateJobIdentifiers(job.resourceId, job.productType); + this.logger.debug({ msg: 'deleting validation entity', logContext: logCtx, jobId, resourceId, productType }); + await this.polygonPartsManagerClient.deleteValidationEntity(resourceId, productType); + activeSpan?.setStatus({ code: SpanStatusCode.OK }).addEvent('ingestionManager.abortIngestion.success', { abortSuccess: true, jobId }); + } + private convertChecksumsToRelativePaths(checksums: IChecksum[]): IChecksum[] { return checksums.map((checksum) => ({ ...checksum, diff --git a/src/ingestion/routes/ingestionRouter.ts b/src/ingestion/routes/ingestionRouter.ts index a9d90659..ff942eda 100644 --- a/src/ingestion/routes/ingestionRouter.ts +++ b/src/ingestion/routes/ingestionRouter.ts @@ -9,6 +9,7 @@ const ingestionRouterFactory: FactoryFunction = (dependencyContainer) => router.post('/', controller.newLayer.bind(controller)); router.put('/:id', controller.updateLayer.bind(controller)); router.put('/:jobId/retry', controller.retryIngestion.bind(controller)); + router.put('/:jobId/abort', controller.abortIngestion.bind(controller)); return router; }; diff --git a/src/serviceClients/jobManagerWrapper.ts b/src/serviceClients/jobManagerWrapper.ts index 19a545ea..a5a1515b 100644 --- a/src/serviceClients/jobManagerWrapper.ts +++ b/src/serviceClients/jobManagerWrapper.ts @@ -60,4 +60,19 @@ export class JobManagerWrapper extends JobManagerClient { throw err; } } + + @withSpanAsyncV4 + public async abortJob(jobId: string): Promise { + const activeSpan = trace.getActiveSpan(); + activeSpan?.updateName('jobManagerWrapper.abortJob'); + + try { + await this.post(`${this.baseUrl}/tasks/abort/${jobId}`, {}); + this.logger.info({ msg: 'successfully aborted job', jobId }); + } catch (err) { + const message = `failed to abort job with id: ${jobId}`; + this.logger.error({ msg: message, err, jobId }); + throw err; + } + } } diff --git a/src/validate/controllers/validateController.ts b/src/validate/controllers/validateController.ts index b6cc33e4..ed7b186f 100644 --- a/src/validate/controllers/validateController.ts +++ b/src/validate/controllers/validateController.ts @@ -1,8 +1,6 @@ import { RequestHandler } from 'express'; -import type { HttpError } from 'express-openapi-validator/dist/framework/types'; import { StatusCodes } from 'http-status-codes'; import { inject, injectable } from 'tsyringe'; -import { FileNotFoundError } from '../../ingestion/errors/ingestionErrors'; import { INGESTION_SCHEMAS_VALIDATOR_SYMBOL, SchemasValidator } from '../../utils/validation/schemasValidator'; import type { ValidateGpkgsResponse } from '../interfaces'; import { ValidateManager } from '../models/validateManager'; @@ -22,9 +20,6 @@ export class ValidateController { const response = await this.validateManager.validateGpkgs(validGpkgInputFilesRequestBody); res.status(StatusCodes.OK).send(response); } catch (error) { - if (error instanceof FileNotFoundError) { - (error as HttpError).status = StatusCodes.NOT_FOUND; //404 - } next(error); } }; diff --git a/tests/integration/ingestion/helpers/ingestionRequestSender.ts b/tests/integration/ingestion/helpers/ingestionRequestSender.ts index c7273494..24294824 100644 --- a/tests/integration/ingestion/helpers/ingestionRequestSender.ts +++ b/tests/integration/ingestion/helpers/ingestionRequestSender.ts @@ -16,4 +16,8 @@ export class IngestionRequestSender { public async retryIngestion(jobId: string): Promise { return supertest.agent(this.app).put(`/ingestion/${jobId}/retry`).set('Content-Type', 'application/json'); } + + public async abortIngestion(jobId: string): Promise { + return supertest.agent(this.app).put(`/ingestion/${jobId}/abort`).set('Content-Type', 'application/json'); + } } diff --git a/tests/integration/ingestion/ingestion.spec.ts b/tests/integration/ingestion/ingestion.spec.ts index c9098ba1..7a5bb41c 100644 --- a/tests/integration/ingestion/ingestion.spec.ts +++ b/tests/integration/ingestion/ingestion.spec.ts @@ -1,6 +1,6 @@ import fs from 'node:fs'; import { faker } from '@faker-js/faker'; -import { OperationStatus, type ICreateJobResponse } from '@map-colonies/mc-priority-queue'; +import { IJobResponse, OperationStatus, type ICreateJobResponse } from '@map-colonies/mc-priority-queue'; import { CORE_VALIDATIONS, getMapServingLayerName, RasterProductTypes } from '@map-colonies/raster-shared'; import { SqliteError } from 'better-sqlite3'; import httpStatusCodes from 'http-status-codes'; @@ -22,6 +22,7 @@ import { createUpdateJobRequest, createUpdateLayerRequest, generateCallbackUrl, + generateMockJob, rasterLayerInputFilesGenerators, rasterLayerMetadataGenerators, } from '../../mocks/mockFactory'; @@ -1562,21 +1563,32 @@ describe('Ingestion', () => { productShapefilePath: `product/${validInputFiles.inputFiles.productShapefilePath}/Product.shp`, }; + const createRetryJob = (options: { + jobId: string; + productId: string; + productType: RasterProductTypes; + status: OperationStatus; + inputFiles?: unknown; + }): IJobResponse => { + const { jobId, productId, productType, status, inputFiles = storedInputFiles } = options; + return generateMockJob({ + id: jobId, + resourceId: productId, + productType, + status, + parameters: { + inputFiles, + }, + }); + }; + describe('Happy Path', () => { it('should return 200 status code when validation is valid and job is FAILED - easy reset job', async () => { const jobId = faker.string.uuid(); const taskId = faker.string.uuid(); const productId = rasterLayerMetadataGenerators.productId(); const productType = rasterLayerMetadataGenerators.productType(); - const retryJob = { - id: jobId, - resourceId: productId, - productType, - status: OperationStatus.FAILED, - parameters: { - inputFiles: storedInputFiles, - }, - }; + const retryJob = createRetryJob({ jobId, productId, productType, status: OperationStatus.FAILED }); const validationTask = { id: taskId, jobId, @@ -1604,15 +1616,7 @@ describe('Ingestion', () => { const taskId = faker.string.uuid(); const productId = rasterLayerMetadataGenerators.productId(); const productType = rasterLayerMetadataGenerators.productType(); - const retryJob = { - id: jobId, - resourceId: productId, - productType, - status: OperationStatus.SUSPENDED, - parameters: { - inputFiles: storedInputFiles, - }, - }; + const retryJob = createRetryJob({ jobId, productId, productType, status: OperationStatus.SUSPENDED }); const validationTask = { id: taskId, jobId, @@ -1640,15 +1644,7 @@ describe('Ingestion', () => { const taskId = faker.string.uuid(); const productId = rasterLayerMetadataGenerators.productId(); const productType = rasterLayerMetadataGenerators.productType(); - const retryJob = { - id: jobId, - resourceId: productId, - productType, - status: OperationStatus.FAILED, - parameters: { - inputFiles: storedInputFiles, - }, - }; + const retryJob = createRetryJob({ jobId, productId, productType, status: OperationStatus.FAILED }); // Simulate old state with fewer checksums (3 items) - new files were added const oldChecksums = validInputFiles.checksums.slice(0, 3); const validationTask = { @@ -1690,114 +1686,74 @@ describe('Ingestion', () => { }); describe('Bad Path', () => { - it('should return 400 BAD_REQUEST status code when job is in PENDING status', async () => { + it('should return 409 CONFLICT status code when job is in PENDING status', async () => { const jobId = faker.string.uuid(); const productId = rasterLayerMetadataGenerators.productId(); const productType = rasterLayerMetadataGenerators.productType(); - const retryJob = { - id: jobId, - resourceId: productId, - productType, - status: OperationStatus.PENDING, - parameters: { - inputFiles: storedInputFiles, - }, - }; + const retryJob = createRetryJob({ jobId, productId, productType, status: OperationStatus.PENDING }); nock(jobManagerURL).get(`/jobs/${jobId}`).query({ shouldReturnTasks: false }).reply(httpStatusCodes.OK, retryJob); const response = await requestSender.retryIngestion(jobId); expect(response).toSatisfyApiSpec(); - expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.status).toBe(httpStatusCodes.CONFLICT); }); - it('should return 400 BAD_REQUEST status code when job is in IN_PROGRESS status', async () => { + it('should return 409 CONFLICT status code when job is in IN_PROGRESS status', async () => { const jobId = faker.string.uuid(); const productId = rasterLayerMetadataGenerators.productId(); const productType = rasterLayerMetadataGenerators.productType(); - const retryJob = { - id: jobId, - resourceId: productId, - productType, - status: OperationStatus.IN_PROGRESS, - parameters: { - inputFiles: storedInputFiles, - }, - }; + const retryJob = createRetryJob({ jobId, productId, productType, status: OperationStatus.IN_PROGRESS }); nock(jobManagerURL).get(`/jobs/${jobId}`).query({ shouldReturnTasks: false }).reply(httpStatusCodes.OK, retryJob); const response = await requestSender.retryIngestion(jobId); expect(response).toSatisfyApiSpec(); - expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.status).toBe(httpStatusCodes.CONFLICT); }); - it('should return 400 BAD_REQUEST status code when job is in COMPLETED status', async () => { + it('should return 409 CONFLICT status code when job is in COMPLETED status', async () => { const jobId = faker.string.uuid(); const productId = rasterLayerMetadataGenerators.productId(); const productType = rasterLayerMetadataGenerators.productType(); - const retryJob = { - id: jobId, - resourceId: productId, - productType, - status: OperationStatus.COMPLETED, - parameters: { - inputFiles: storedInputFiles, - }, - }; + const retryJob = createRetryJob({ jobId, productId, productType, status: OperationStatus.COMPLETED }); nock(jobManagerURL).get(`/jobs/${jobId}`).query({ shouldReturnTasks: false }).reply(httpStatusCodes.OK, retryJob); const response = await requestSender.retryIngestion(jobId); expect(response).toSatisfyApiSpec(); - expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.status).toBe(httpStatusCodes.CONFLICT); }); - it('should return 400 BAD_REQUEST status code when job is in EXPIRED status', async () => { + it('should return 409 CONFLICT status code when job is in EXPIRED status', async () => { const jobId = faker.string.uuid(); const productId = rasterLayerMetadataGenerators.productId(); const productType = rasterLayerMetadataGenerators.productType(); - const retryJob = { - id: jobId, - resourceId: productId, - productType, - status: OperationStatus.EXPIRED, - parameters: { - inputFiles: storedInputFiles, - }, - }; + const retryJob = createRetryJob({ jobId, productId, productType, status: OperationStatus.EXPIRED }); nock(jobManagerURL).get(`/jobs/${jobId}`).query({ shouldReturnTasks: false }).reply(httpStatusCodes.OK, retryJob); const response = await requestSender.retryIngestion(jobId); expect(response).toSatisfyApiSpec(); - expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.status).toBe(httpStatusCodes.CONFLICT); }); - it('should return 400 BAD_REQUEST status code when job is in ABORTED status', async () => { + it('should return 409 CONFLICT status code when job is in ABORTED status', async () => { const jobId = faker.string.uuid(); const productId = rasterLayerMetadataGenerators.productId(); const productType = rasterLayerMetadataGenerators.productType(); - const retryJob = { - id: jobId, - resourceId: productId, - productType, - status: OperationStatus.ABORTED, - parameters: { - inputFiles: storedInputFiles, - }, - }; + const retryJob = createRetryJob({ jobId, productId, productType, status: OperationStatus.ABORTED }); nock(jobManagerURL).get(`/jobs/${jobId}`).query({ shouldReturnTasks: false }).reply(httpStatusCodes.OK, retryJob); const response = await requestSender.retryIngestion(jobId); expect(response).toSatisfyApiSpec(); - expect(response.status).toBe(httpStatusCodes.BAD_REQUEST); + expect(response.status).toBe(httpStatusCodes.CONFLICT); }); }); @@ -1806,15 +1762,7 @@ describe('Ingestion', () => { const jobId = faker.string.uuid(); const productId = rasterLayerMetadataGenerators.productId(); const productType = rasterLayerMetadataGenerators.productType(); - const retryJob = { - id: jobId, - resourceId: productId, - productType, - status: OperationStatus.FAILED, - parameters: { - inputFiles: storedInputFiles, - }, - }; + const retryJob = createRetryJob({ jobId, productId, productType, status: OperationStatus.FAILED }); const otherTask = { id: faker.string.uuid(), jobId, @@ -1838,15 +1786,7 @@ describe('Ingestion', () => { const jobId = faker.string.uuid(); const productId = rasterLayerMetadataGenerators.productId(); const productType = rasterLayerMetadataGenerators.productType(); - const retryJob = { - id: jobId, - resourceId: productId, - productType, - status: OperationStatus.FAILED, - parameters: { - inputFiles: storedInputFiles, - }, - }; + const retryJob = createRetryJob({ jobId, productId, productType, status: OperationStatus.FAILED }); nock(jobManagerURL).get(`/jobs/${jobId}`).query({ shouldReturnTasks: false }).reply(httpStatusCodes.OK, retryJob); nock(jobManagerURL).get(`/jobs/${jobId}/tasks`).reply(httpStatusCodes.OK, []); @@ -1864,15 +1804,7 @@ describe('Ingestion', () => { const taskId = faker.string.uuid(); const productId = rasterLayerMetadataGenerators.productId(); const productType = rasterLayerMetadataGenerators.productType(); - const retryJob = { - id: jobId, - resourceId: productId, - productType, - status: OperationStatus.FAILED, - parameters: { - inputFiles: storedInputFiles, - }, - }; + const retryJob = createRetryJob({ jobId, productId, productType, status: OperationStatus.FAILED }); const validationTask = { id: taskId, jobId, @@ -1901,15 +1833,7 @@ describe('Ingestion', () => { const taskId = faker.string.uuid(); const productId = rasterLayerMetadataGenerators.productId(); const productType = rasterLayerMetadataGenerators.productType(); - const retryJob = { - id: jobId, - resourceId: productId, - productType, - status: OperationStatus.FAILED, - parameters: { - inputFiles: storedInputFiles, - }, - }; + const retryJob = createRetryJob({ jobId, productId, productType, status: OperationStatus.FAILED }); const validationTask = { id: taskId, jobId, @@ -1937,18 +1861,16 @@ describe('Ingestion', () => { const taskId = faker.string.uuid(); const productId = rasterLayerMetadataGenerators.productId(); const productType = rasterLayerMetadataGenerators.productType(); - const retryJob = { - id: jobId, - resourceId: productId, + const retryJob = createRetryJob({ + jobId, + productId, productType, status: OperationStatus.FAILED, - parameters: { - inputFiles: { - // Invalid structure - missing required fields - invalidField: 'invalid', - }, + inputFiles: { + // Invalid structure - missing required fields + invalidField: 'invalid', }, - }; + }); const validationTask = { id: taskId, jobId, @@ -1989,15 +1911,7 @@ describe('Ingestion', () => { const jobId = faker.string.uuid(); const productId = rasterLayerMetadataGenerators.productId(); const productType = rasterLayerMetadataGenerators.productType(); - const retryJob = { - id: jobId, - resourceId: productId, - productType, - status: OperationStatus.FAILED, - parameters: { - inputFiles: storedInputFiles, - }, - }; + const retryJob = createRetryJob({ jobId, productId, productType, status: OperationStatus.FAILED }); nock(jobManagerURL).get(`/jobs/${jobId}`).query({ shouldReturnTasks: false }).reply(httpStatusCodes.OK, retryJob); nock(jobManagerURL).get(`/jobs/${jobId}/tasks`).reply(httpStatusCodes.INTERNAL_SERVER_ERROR); @@ -2013,15 +1927,7 @@ describe('Ingestion', () => { const taskId = faker.string.uuid(); const productId = rasterLayerMetadataGenerators.productId(); const productType = rasterLayerMetadataGenerators.productType(); - const retryJob = { - id: jobId, - resourceId: productId, - productType, - status: OperationStatus.FAILED, - parameters: { - inputFiles: storedInputFiles, - }, - }; + const retryJob = createRetryJob({ jobId, productId, productType, status: OperationStatus.FAILED }); const oldChecksums = validInputFiles.checksums.slice(0, 3); const validationTask = { id: taskId, @@ -2050,15 +1956,7 @@ describe('Ingestion', () => { const taskId = faker.string.uuid(); const productId = rasterLayerMetadataGenerators.productId(); const productType = rasterLayerMetadataGenerators.productType(); - const retryJob = { - id: jobId, - resourceId: productId, - productType, - status: OperationStatus.FAILED, - parameters: { - inputFiles: storedInputFiles, - }, - }; + const retryJob = createRetryJob({ jobId, productId, productType, status: OperationStatus.FAILED }); const oldChecksums = validInputFiles.checksums.slice(0, 3); const validationTask = { id: taskId, @@ -2101,15 +1999,7 @@ describe('Ingestion', () => { const taskId = faker.string.uuid(); const productId = rasterLayerMetadataGenerators.productId(); const productType = rasterLayerMetadataGenerators.productType(); - const retryJob = { - id: jobId, - resourceId: productId, - productType, - status: OperationStatus.FAILED, - parameters: { - inputFiles: storedInputFiles, - }, - }; + const retryJob = createRetryJob({ jobId, productId, productType, status: OperationStatus.FAILED }); // Simulate old state with fewer checksums (3 items) - new files were added const oldChecksums = validInputFiles.checksums.slice(0, 3); const validationTask = { @@ -2146,15 +2036,13 @@ describe('Ingestion', () => { metadataShapefilePath: 'metadata/nonexistent-shapefile/ShapeMetadata.shp', productShapefilePath: `product/${validInputFiles.inputFiles.productShapefilePath}/Product.shp`, }; - const retryJob = { - id: jobId, - resourceId: productId, + const retryJob = createRetryJob({ + jobId, + productId, productType, status: OperationStatus.FAILED, - parameters: { - inputFiles: nonExistentInputFiles, - }, - }; + inputFiles: nonExistentInputFiles, + }); const oldChecksums = validInputFiles.checksums.slice(0, 3); const validationTask = { id: taskId, @@ -2189,15 +2077,13 @@ describe('Ingestion', () => { metadataShapefilePath: `metadata/${validInputFiles.inputFiles.metadataShapefilePath}/ShapeMetadata.shp`, productShapefilePath: `product/${validInputFiles.inputFiles.productShapefilePath}/Product.shp`, }; - const retryJob = { - id: jobId, - resourceId: productId, + const retryJob = createRetryJob({ + jobId, + productId, productType, status: OperationStatus.FAILED, - parameters: { - inputFiles: nonExistentInputFiles, - }, - }; + inputFiles: nonExistentInputFiles, + }); const oldChecksums = validInputFiles.checksums.slice(0, 3); const validationTask = { id: taskId, @@ -2223,4 +2109,91 @@ describe('Ingestion', () => { }); }); }); + + describe('PUT /ingestion/:jobId/abort', () => { + const abortableStatuses = [OperationStatus.FAILED, OperationStatus.SUSPENDED, OperationStatus.IN_PROGRESS, OperationStatus.PENDING]; + const nonAbortableStatuses = [OperationStatus.COMPLETED, OperationStatus.ABORTED]; + + describe('Happy Path', () => { + it.each(abortableStatuses)('should return 200 status code when aborting job with %s status', async (status) => { + const mockJob = generateMockJob({ status }); + const tasks = [ + { id: faker.string.uuid(), type: 'validation', status: OperationStatus.COMPLETED }, + { id: faker.string.uuid(), type: 'init', status: OperationStatus.COMPLETED }, + ]; + + nock(jobManagerURL).get(`/jobs/${mockJob.id}`).query({ shouldReturnTasks: false }).reply(httpStatusCodes.OK, mockJob); + nock(jobManagerURL).get(`/jobs/${mockJob.id}/tasks`).reply(httpStatusCodes.OK, tasks); + nock(jobManagerURL).post(`/tasks/abort/${mockJob.id}`).reply(httpStatusCodes.OK); + nock(polygonPartsManagerURL) + .delete('/polygonParts/validate') + .query({ productType: mockJob.productType, productId: mockJob.resourceId }) + .reply(httpStatusCodes.NO_CONTENT); + + const response = await requestSender.abortIngestion(mockJob.id); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.OK); + }); + + it('should return 200 status code when aborting job with no tasks', async () => { + const mockJob = generateMockJob({ status: OperationStatus.FAILED }); + + nock(jobManagerURL).get(`/jobs/${mockJob.id}`).query({ shouldReturnTasks: false }).reply(httpStatusCodes.OK, mockJob); + nock(jobManagerURL).get(`/jobs/${mockJob.id}/tasks`).reply(httpStatusCodes.OK, []); + nock(jobManagerURL).post(`/tasks/abort/${mockJob.id}`).reply(httpStatusCodes.OK); + nock(polygonPartsManagerURL) + .delete('/polygonParts/validate') + .query({ productType: mockJob.productType, productId: mockJob.resourceId }) + .reply(httpStatusCodes.NO_CONTENT); + + const response = await requestSender.abortIngestion(mockJob.id); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.OK); + }); + }); + + describe('Bad Path', () => { + it.each(nonAbortableStatuses)('should return 409 CONFLICT status code when job is in %s status', async (status) => { + const mockJob = generateMockJob({ status }); + + nock(jobManagerURL).get(`/jobs/${mockJob.id}`).query({ shouldReturnTasks: false }).reply(httpStatusCodes.OK, mockJob); + + const response = await requestSender.abortIngestion(mockJob.id); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.CONFLICT); + }); + + it.each(abortableStatuses)('should return 409 CONFLICT status code when job with %s status has finalize task', async (status) => { + const mockJob = generateMockJob({ status }); + const tasks = [ + { id: faker.string.uuid(), type: 'validation', status: OperationStatus.COMPLETED }, + { id: faker.string.uuid(), type: configMock.get('jobManager.finalizeTaskType'), status: OperationStatus.PENDING }, + ]; + + nock(jobManagerURL).get(`/jobs/${mockJob.id}`).query({ shouldReturnTasks: false }).reply(httpStatusCodes.OK, mockJob); + nock(jobManagerURL).get(`/jobs/${mockJob.id}/tasks`).reply(httpStatusCodes.OK, tasks); + + const response = await requestSender.abortIngestion(mockJob.id); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.CONFLICT); + }); + }); + + describe('Sad Path', () => { + it('should return 404 NOT_FOUND status code when job does not exist', async () => { + const jobId = faker.string.uuid(); + + nock(jobManagerURL).get(`/jobs/${jobId}`).query({ shouldReturnTasks: false }).reply(httpStatusCodes.NOT_FOUND); + + const response = await requestSender.abortIngestion(jobId); + + expect(response).toSatisfyApiSpec(); + expect(response.status).toBe(httpStatusCodes.NOT_FOUND); + }); + }); + }); }); diff --git a/tests/integration/ingestion/jobManagerWrapper.spec.ts b/tests/integration/ingestion/jobManagerWrapper.spec.ts new file mode 100644 index 00000000..6c1e2a7f --- /dev/null +++ b/tests/integration/ingestion/jobManagerWrapper.spec.ts @@ -0,0 +1,83 @@ +import { faker } from '@faker-js/faker'; +import jsLogger from '@map-colonies/js-logger'; +import { trace } from '@opentelemetry/api'; +import nock from 'nock'; +import { JobManagerWrapper } from '../../../src/serviceClients/jobManagerWrapper'; +import { clear as clearConfig, configMock, registerDefaultConfig } from '../../mocks/configMock'; + +describe('jobManagerWrapper integration', () => { + let jobManagerWrapper: JobManagerWrapper; + + beforeEach(() => { + registerDefaultConfig(); + jobManagerWrapper = new JobManagerWrapper(configMock, jsLogger({ enabled: false }), trace.getTracer('testTracer')); + }); + + afterEach(() => { + nock.cleanAll(); + clearConfig(); + jest.clearAllMocks(); + jest.restoreAllMocks(); + }); + + describe('abortJob', () => { + let jobId: string; + + beforeEach(() => { + jobId = faker.string.uuid(); + }); + + describe('Happy Path', () => { + it('should successfully send abort request to Job Manager', async () => { + nock('http://jobmanagerurl').post(`/tasks/abort/${jobId}`).reply(200); + + const action = async () => jobManagerWrapper.abortJob(jobId); + + await expect(action()).resolves.not.toThrow(); + }); + + it('should call correct endpoint /tasks/abort/{jobId}', async () => { + const scope = nock('http://jobmanagerurl').post(`/tasks/abort/${jobId}`).reply(200); + + const action = async () => jobManagerWrapper.abortJob(jobId); + + await expect(action()).resolves.not.toThrow(); + expect(scope.isDone()).toBe(true); + }); + }); + + describe('Sad Path', () => { + it('should throw error when job not found', async () => { + nock('http://jobmanagerurl').post(`/tasks/abort/${jobId}`).reply(404, { message: 'Job not found' }); + + const action = async () => jobManagerWrapper.abortJob(jobId); + + await expect(action()).rejects.toThrow(); + }); + + it('should throw error when Job Manager has internal server error', async () => { + nock('http://jobmanagerurl').post(`/tasks/abort/${jobId}`).reply(500, { message: 'Internal server error' }); + + const action = async () => jobManagerWrapper.abortJob(jobId); + + await expect(action()).rejects.toThrow(); + }); + + it('should handle network timeout', async () => { + nock('http://jobmanagerurl').post(`/tasks/abort/${jobId}`).replyWithError({ code: 'ETIMEDOUT', message: 'Timeout' }); + + const action = async () => jobManagerWrapper.abortJob(jobId); + + await expect(action()).rejects.toThrow(); + }); + + it('should handle connection refused', async () => { + nock('http://jobmanagerurl').post(`/tasks/abort/${jobId}`).replyWithError({ code: 'ECONNREFUSED', message: 'Connection refused' }); + + const action = async () => jobManagerWrapper.abortJob(jobId); + + await expect(action()).rejects.toThrow(); + }); + }); + }); +}); diff --git a/tests/mocks/mockFactory.ts b/tests/mocks/mockFactory.ts index e562336d..31406201 100644 --- a/tests/mocks/mockFactory.ts +++ b/tests/mocks/mockFactory.ts @@ -2,7 +2,7 @@ import { join, relative } from 'node:path'; import { faker } from '@faker-js/faker'; import { RecordType, TileOutputFormat } from '@map-colonies/mc-model-types'; -import { OperationStatus, type ICreateJobBody, type IFindJobsByCriteriaBody } from '@map-colonies/mc-priority-queue'; +import { OperationStatus, type ICreateJobBody, type IFindJobsByCriteriaBody, type IJobResponse } from '@map-colonies/mc-priority-queue'; import { Checksum, CORE_VALIDATIONS, @@ -17,6 +17,7 @@ import { type InputFiles, type NewRasterLayerMetadata, type UpdateRasterLayerMetadata, + JobTypes, } from '@map-colonies/raster-shared'; import { Domain, RecordStatus, TilesMimeFormat } from '@map-colonies/types'; import { randomPolygon } from '@turf/turf'; @@ -228,6 +229,38 @@ export const generateChecksum = (): Checksum => { export const generateCallbackUrl = (): CallbackUrlsTargetArray[number] => faker.internet.url({ protocol: faker.helpers.arrayElement(['http', 'https']) }); +export const generateMockJob = (overrides: Partial> = {}): IJobResponse => { + const defaults: IJobResponse = { + id: faker.string.uuid(), + resourceId: rasterLayerMetadataGenerators.productId(), + version: rasterLayerMetadataGenerators.productVersion(), + type: JobTypes.Ingestion_New, + domain: Domain.RASTER, + productName: rasterLayerMetadataGenerators.productName(), + productType: rasterLayerMetadataGenerators.productType(), + status: faker.helpers.enumValue(OperationStatus), + created: faker.date.past().toISOString(), + updated: faker.date.recent().toISOString(), + priority: faker.number.int({ min: 0, max: 5 }), + internalId: faker.string.uuid(), + producerName: rasterLayerMetadataGenerators.producerName(), + parameters: {}, + percentage: faker.number.int({ min: 0, max: 100 }), + taskCount: 1, + completedTasks: 0, + inProgressTasks: 0, + failedTasks: 0, + pendingTasks: 1, + expiredTasks: 0, + abortedTasks: 0, + description: '', + reason: '', + isCleaned: false, + }; + + return { ...defaults, ...overrides }; +}; + export const rasterLayerMetadataGenerators: RasterLayerMetadataPropertiesGenerators = { id: (): string => faker.string.uuid(), classification: (): string => faker.number.int({ max: 100 }).toString(), diff --git a/tests/unit/ingestion/models/ingestionManager.spec.ts b/tests/unit/ingestion/models/ingestionManager.spec.ts index 5af37ebf..55ebc0df 100644 --- a/tests/unit/ingestion/models/ingestionManager.spec.ts +++ b/tests/unit/ingestion/models/ingestionManager.spec.ts @@ -22,9 +22,9 @@ import { clear as clearConfig, configMock, registerDefaultConfig } from '../../. import { generateCatalogLayerResponse, generateChecksum, + generateMockJob, generateNewLayerRequest, generateUpdateLayerRequest, - rasterLayerMetadataGenerators, } from '../../../mocks/mockFactory'; import { ChecksumProcessor } from '../../../../src/utils/hash/interfaces'; import { CHECKSUM_PROCESSOR } from '../../../../src/utils/hash/constants'; @@ -533,11 +533,9 @@ describe('IngestionManager', () => { it('should reset job when validation task has no errors and job is Failed', async () => { const jobId = faker.string.uuid(); const taskId = faker.string.uuid(); - const mockJob = { + const mockJob = generateMockJob({ id: jobId, status: OperationStatus.FAILED, - productType: 'Orthophoto', - resourceId: rasterLayerMetadataGenerators.productId(), parameters: { inputFiles: { gpkgFilesPath: ['/path/to/file.gpkg'], @@ -545,7 +543,7 @@ describe('IngestionManager', () => { productShapefilePath: '/path/to/product.shp', }, }, - }; + }); const mockValidationTask = { id: taskId, jobId, @@ -573,11 +571,9 @@ describe('IngestionManager', () => { it('should reset job when job status is SUSPENDED and validation passed', async () => { const jobId = faker.string.uuid(); const taskId = faker.string.uuid(); - const mockJob = { + const mockJob = generateMockJob({ id: jobId, status: OperationStatus.SUSPENDED, - productType: 'Orthophoto', - resourceId: rasterLayerMetadataGenerators.productId(), parameters: { inputFiles: { gpkgFilesPath: ['/path/to/file.gpkg'], @@ -585,7 +581,7 @@ describe('IngestionManager', () => { productShapefilePath: '/path/to/product.shp', }, }, - }; + }); const mockValidationTask = { id: taskId, jobId, @@ -611,16 +607,14 @@ describe('IngestionManager', () => { }); it('should update task with new checksums when shapefile has changed and job is SUSPENDED', async () => { - const jobId = faker.string.uuid(); - const taskId = faker.string.uuid(); const oldChecksum = 'oldChecksum123'; const newChecksum = 'newChecksum456'; + const jobId = faker.string.uuid(); + const taskId = faker.string.uuid(); - const mockJob = { + const mockJob = generateMockJob({ id: jobId, status: OperationStatus.SUSPENDED, - productType: 'Orthophoto', - resourceId: rasterLayerMetadataGenerators.productId(), parameters: { inputFiles: { gpkgFilesPath: ['/path/to/file.gpkg'], @@ -628,7 +622,7 @@ describe('IngestionManager', () => { productShapefilePath: '/path/to/product.shp', }, }, - }; + }); const mockValidationTask = { id: taskId, jobId, @@ -687,16 +681,14 @@ describe('IngestionManager', () => { }); it('should update task with new checksums when shapefile has changed and job is FAILED', async () => { - const jobId = faker.string.uuid(); - const taskId = faker.string.uuid(); const oldChecksum = 'oldChecksum123'; const newChecksum = 'newChecksum456'; + const jobId = faker.string.uuid(); + const taskId = faker.string.uuid(); - const mockJob = { + const mockJob = generateMockJob({ id: jobId, status: OperationStatus.FAILED, - productType: 'Orthophoto', - resourceId: rasterLayerMetadataGenerators.productId(), parameters: { inputFiles: { gpkgFilesPath: ['/path/to/file.gpkg'], @@ -704,7 +696,7 @@ describe('IngestionManager', () => { productShapefilePath: '/path/to/product.shp', }, }, - }; + }); const mockValidationTask = { id: taskId, jobId, @@ -763,15 +755,13 @@ describe('IngestionManager', () => { }); it('should throw ConflictError when shapefile has not changed', async () => { + const existingChecksum = { fileName: 'metadata.shp', checksum: 'sameChecksum123' }; const jobId = faker.string.uuid(); const taskId = faker.string.uuid(); - const existingChecksum = { fileName: 'metadata.shp', checksum: 'sameChecksum123' }; - const mockJob = { + const mockJob = generateMockJob({ id: jobId, status: OperationStatus.FAILED, - productType: 'Orthophoto', - resourceId: rasterLayerMetadataGenerators.productId(), parameters: { inputFiles: { gpkgFilesPath: ['/path/to/file.gpkg'], @@ -779,7 +769,7 @@ describe('IngestionManager', () => { productShapefilePath: '/path/to/product.shp', }, }, - }; + }); const mockValidationTask = { id: taskId, jobId, @@ -805,12 +795,9 @@ describe('IngestionManager', () => { it('should throw BadRequestError when metadataShapefilePath is missing', async () => { const jobId = faker.string.uuid(); const taskId = faker.string.uuid(); - - const mockJob = { + const mockJob = generateMockJob({ id: jobId, status: OperationStatus.FAILED, - productType: 'Orthophoto', - resourceId: rasterLayerMetadataGenerators.productId(), parameters: { inputFiles: { gpkgFilesPath: ['/path/to/file.gpkg'], @@ -818,7 +805,7 @@ describe('IngestionManager', () => { productShapefilePath: '/path/to/product.shp', }, }, - }; + }); const mockValidationTask = { id: taskId, jobId, @@ -840,13 +827,11 @@ describe('IngestionManager', () => { expect(resetJobSpy).not.toHaveBeenCalled(); }); - it('should throw BadRequestError when job status is PENDING', async () => { + it('should throw ConflictError when job status is PENDING', async () => { const jobId = faker.string.uuid(); - const mockJob = { + const mockJob = generateMockJob({ id: jobId, status: OperationStatus.PENDING, - productType: 'Orthophoto', - resourceId: rasterLayerMetadataGenerators.productId(), parameters: { inputFiles: { gpkgFilesPath: ['/path/to/file.gpkg'], @@ -854,23 +839,21 @@ describe('IngestionManager', () => { productShapefilePath: '/path/to/product.shp', }, }, - }; + }); getJobSpy.mockResolvedValue(mockJob); - await expect(ingestionManager.retryIngestion(jobId)).rejects.toThrow(BadRequestError); + await expect(ingestionManager.retryIngestion(jobId)).rejects.toThrow(ConflictError); expect(getTasksForJobSpy).not.toHaveBeenCalled(); expect(updateTaskSpy).not.toHaveBeenCalled(); expect(resetJobSpy).not.toHaveBeenCalled(); }); - it('should throw BadRequestError when job status is IN_PROGRESS', async () => { + it('should throw ConflictError when job status is IN_PROGRESS', async () => { const jobId = faker.string.uuid(); - const mockJob = { + const mockJob = generateMockJob({ id: jobId, status: OperationStatus.IN_PROGRESS, - productType: 'Orthophoto', - resourceId: rasterLayerMetadataGenerators.productId(), parameters: { inputFiles: { gpkgFilesPath: ['/path/to/file.gpkg'], @@ -878,22 +861,20 @@ describe('IngestionManager', () => { productShapefilePath: '/path/to/product.shp', }, }, - }; + }); getJobSpy.mockResolvedValue(mockJob); - await expect(ingestionManager.retryIngestion(jobId)).rejects.toThrow(BadRequestError); + await expect(ingestionManager.retryIngestion(jobId)).rejects.toThrow(ConflictError); expect(updateTaskSpy).not.toHaveBeenCalled(); expect(resetJobSpy).not.toHaveBeenCalled(); }); it('should throw NotFoundError when validation task is not found', async () => { const jobId = faker.string.uuid(); - const mockJob = { + const mockJob = generateMockJob({ id: jobId, status: OperationStatus.FAILED, - productType: 'Orthophoto', - resourceId: rasterLayerMetadataGenerators.productId(), parameters: { inputFiles: { gpkgFilesPath: ['/path/to/file.gpkg'], @@ -901,7 +882,7 @@ describe('IngestionManager', () => { productShapefilePath: '/path/to/product.shp', }, }, - }; + }); getJobSpy.mockResolvedValue(mockJob); getTasksForJobSpy.mockResolvedValue([]); @@ -913,12 +894,9 @@ describe('IngestionManager', () => { it('should find validation task among multiple tasks', async () => { const jobId = faker.string.uuid(); - const taskId = faker.string.uuid(); - const mockJob = { + const mockJob = generateMockJob({ id: jobId, status: OperationStatus.FAILED, - productType: 'Orthophoto', - resourceId: rasterLayerMetadataGenerators.productId(), parameters: { inputFiles: { gpkgFilesPath: ['/path/to/file.gpkg'], @@ -926,7 +904,7 @@ describe('IngestionManager', () => { productShapefilePath: '/path/to/product.shp', }, }, - }; + }); const mockTasks = [ { id: faker.string.uuid(), @@ -936,7 +914,7 @@ describe('IngestionManager', () => { parameters: {}, }, { - id: taskId, + id: faker.string.uuid(), jobId, type: 'validation', status: OperationStatus.COMPLETED, @@ -960,4 +938,183 @@ describe('IngestionManager', () => { expect(resetJobSpy).toHaveBeenCalledWith(jobId); }); }); + + describe('abortIngestion', () => { + let getJobSpy: jest.SpyInstance; + let getTasksForJobSpy: jest.SpyInstance; + let abortJobSpy: jest.SpyInstance; + + beforeEach(() => { + getJobSpy = jest.spyOn(JobManagerWrapper.prototype, 'getJob'); + getTasksForJobSpy = jest.spyOn(JobManagerWrapper.prototype, 'getTasksForJob'); + abortJobSpy = jest.spyOn(JobManagerWrapper.prototype, 'abortJob'); + mockPolygonPartsManagerClient.deleteValidationEntity.mockClear(); + }); + + it.each([[OperationStatus.FAILED], [OperationStatus.SUSPENDED], [OperationStatus.IN_PROGRESS], [OperationStatus.PENDING]])( + 'should successfully abort job with status %s', + async (status) => { + const mockJob = generateMockJob({ status }); + const mockTasks = [ + { id: faker.string.uuid(), type: 'validation', status: OperationStatus.COMPLETED }, + { id: faker.string.uuid(), type: 'create-tasks', status: OperationStatus.FAILED }, + ]; + + getJobSpy.mockResolvedValue(mockJob); + getTasksForJobSpy.mockResolvedValue(mockTasks); + abortJobSpy.mockResolvedValue(undefined); + mockPolygonPartsManagerClient.deleteValidationEntity.mockResolvedValue(undefined); + + await ingestionManager.abortIngestion(mockJob.id); + + expect(getJobSpy).toHaveBeenCalledWith(mockJob.id); + expect(getTasksForJobSpy).toHaveBeenCalledWith(mockJob.id); + expect(abortJobSpy).toHaveBeenCalledWith(mockJob.id); + expect(mockPolygonPartsManagerClient.deleteValidationEntity).toHaveBeenCalledWith(mockJob.resourceId, mockJob.productType); + expect(mockPolygonPartsManagerClient.deleteValidationEntity).toHaveBeenCalledTimes(1); + } + ); + + it('should successfully abort when no tasks exist for the requested job', async () => { + const mockJob = generateMockJob({ status: OperationStatus.FAILED }); + + getJobSpy.mockResolvedValue(mockJob); + getTasksForJobSpy.mockResolvedValue([]); + abortJobSpy.mockResolvedValue(undefined); + mockPolygonPartsManagerClient.deleteValidationEntity.mockResolvedValue(undefined); + + await ingestionManager.abortIngestion(mockJob.id); + + expect(getJobSpy).toHaveBeenCalledWith(mockJob.id); + expect(getTasksForJobSpy).toHaveBeenCalledWith(mockJob.id); + expect(abortJobSpy).toHaveBeenCalledWith(mockJob.id); + expect(mockPolygonPartsManagerClient.deleteValidationEntity).toHaveBeenCalledWith(mockJob.resourceId, mockJob.productType); + expect(mockPolygonPartsManagerClient.deleteValidationEntity).toHaveBeenCalledTimes(1); + }); + + it.each([[OperationStatus.COMPLETED], [OperationStatus.ABORTED]])('should reject abort for job with invalid status %s', async (status) => { + const mockJob = generateMockJob({ status }); + + getJobSpy.mockResolvedValue(mockJob); + + const action = ingestionManager.abortIngestion(mockJob.id); + + await expect(action).rejects.toThrow(ConflictError); + expect(getTasksForJobSpy).not.toHaveBeenCalled(); + expect(abortJobSpy).not.toHaveBeenCalled(); + expect(mockPolygonPartsManagerClient.deleteValidationEntity).not.toHaveBeenCalled(); + }); + + it.each([[OperationStatus.FAILED], [OperationStatus.SUSPENDED], [OperationStatus.IN_PROGRESS], [OperationStatus.PENDING]])( + 'should throw Conflict Error when finalize task exists for job with status %s', + async (status) => { + const finalizeTaskId = faker.string.uuid(); + const mockJob = generateMockJob({ status }); + const mockTasks = [ + { id: faker.string.uuid(), type: 'validation', status: OperationStatus.COMPLETED }, + { id: finalizeTaskId, type: 'finalize', status: OperationStatus.PENDING }, + ]; + + getJobSpy.mockResolvedValue(mockJob); + getTasksForJobSpy.mockResolvedValue(mockTasks); + + const action = ingestionManager.abortIngestion(mockJob.id); + + await expect(action).rejects.toThrow(ConflictError); + expect(abortJobSpy).not.toHaveBeenCalled(); + expect(mockPolygonPartsManagerClient.deleteValidationEntity).not.toHaveBeenCalled(); + } + ); + + it.each([['validation'], ['create-tasks'], ['merge']])( + 'should allow abort when only non-finalize tasks exist (task type: %s)', + async (taskType) => { + const mockJob = generateMockJob({ status: OperationStatus.FAILED }); + const mockTasks = [{ id: faker.string.uuid(), type: taskType, status: OperationStatus.COMPLETED }]; + + getJobSpy.mockResolvedValue(mockJob); + getTasksForJobSpy.mockResolvedValue(mockTasks); + abortJobSpy.mockResolvedValue(undefined); + mockPolygonPartsManagerClient.deleteValidationEntity.mockResolvedValue(undefined); + + await ingestionManager.abortIngestion(mockJob.id); + + expect(getJobSpy).toHaveBeenCalledWith(mockJob.id); + expect(getTasksForJobSpy).toHaveBeenCalledWith(mockJob.id); + expect(abortJobSpy).toHaveBeenCalledWith(mockJob.id); + expect(mockPolygonPartsManagerClient.deleteValidationEntity).toHaveBeenCalledWith(mockJob.resourceId, mockJob.productType); + expect(mockPolygonPartsManagerClient.deleteValidationEntity).toHaveBeenCalledTimes(1); + } + ); + + it('should throw NotFoundError when job does not exist', async () => { + const jobId = faker.string.uuid(); + getJobSpy.mockRejectedValue(new NotFoundError('Job not found')); + + const action = ingestionManager.abortIngestion(jobId); + + await expect(action).rejects.toThrow(NotFoundError); + expect(getTasksForJobSpy).not.toHaveBeenCalled(); + expect(abortJobSpy).not.toHaveBeenCalled(); + expect(mockPolygonPartsManagerClient.deleteValidationEntity).not.toHaveBeenCalled(); + }); + + it.each([[OperationStatus.FAILED], [OperationStatus.SUSPENDED], [OperationStatus.IN_PROGRESS], [OperationStatus.PENDING]])( + 'should throw error when job has invalid productId for status %s', + async (status) => { + const mockJob = generateMockJob({ resourceId: undefined, status }); + + getJobSpy.mockResolvedValue(mockJob); + getTasksForJobSpy.mockResolvedValue([]); + abortJobSpy.mockResolvedValue(undefined); + + const action = ingestionManager.abortIngestion(mockJob.id); + + await expect(action).rejects.toThrow(); + expect(mockPolygonPartsManagerClient.deleteValidationEntity).not.toHaveBeenCalled(); + } + ); + + it.each([[OperationStatus.FAILED], [OperationStatus.SUSPENDED], [OperationStatus.IN_PROGRESS], [OperationStatus.PENDING]])( + 'should throw error when job has invalid productType for status %s', + async (status) => { + const mockJob = generateMockJob({ productType: undefined, status }); + + getJobSpy.mockResolvedValue(mockJob); + getTasksForJobSpy.mockResolvedValue([]); + abortJobSpy.mockResolvedValue(undefined); + + const action = ingestionManager.abortIngestion(mockJob.id); + + await expect(action).rejects.toThrow(); + expect(mockPolygonPartsManagerClient.deleteValidationEntity).not.toHaveBeenCalled(); + } + ); + + it('should propagate error when Job Manager abort fails', async () => { + const mockJob = generateMockJob({ status: OperationStatus.FAILED }); + + getJobSpy.mockResolvedValue(mockJob); + getTasksForJobSpy.mockResolvedValue([]); + abortJobSpy.mockRejectedValue(new Error('Job Manager failed')); + + const action = ingestionManager.abortIngestion(mockJob.id); + + await expect(action).rejects.toThrow(Error); + expect(mockPolygonPartsManagerClient.deleteValidationEntity).not.toHaveBeenCalled(); + }); + + it('should handle getTasksForJob failure', async () => { + const mockJob = generateMockJob({ status: OperationStatus.FAILED }); + + getJobSpy.mockResolvedValue(mockJob); + getTasksForJobSpy.mockRejectedValue(new Error('Failed to fetch tasks')); + + const action = ingestionManager.abortIngestion(mockJob.id); + + await expect(action).rejects.toThrow(Error); + expect(abortJobSpy).not.toHaveBeenCalled(); + expect(mockPolygonPartsManagerClient.deleteValidationEntity).not.toHaveBeenCalled(); + }); + }); });