From fe073a4dd50b79468497081e81284c3662c4fb14 Mon Sep 17 00:00:00 2001 From: lauren5656 Date: Mon, 20 Apr 2026 09:57:47 +0100 Subject: [PATCH 01/30] [HOTE-1099] feat: Results processor --- lambdas/package-lock.json | 897 ++++++++++-------- lambdas/package.json | 1 + .../hiv-result-processor-lambda/index.test.ts | 111 +++ .../src/hiv-result-processor-lambda/index.ts | 51 + .../hiv-result-processor-lambda/init.test.ts | 80 ++ .../src/hiv-result-processor-lambda/init.ts | 32 + .../src/hiv-result-processor-lambda/models.ts | 48 + .../result-status-lambda-service.test.ts | 95 ++ .../result-status-lambda-service.ts | 69 ++ .../task-builder.test.ts | 104 ++ .../task-builder.ts | 72 ++ .../validation-service.test.ts | 436 +++++++++ .../validation-service.ts | 228 +++++ .../hiv-result-processor-lambda/validation.ts | 35 + local-environment/infra/main.tf | 26 + 15 files changed, 1913 insertions(+), 372 deletions(-) create mode 100644 lambdas/src/hiv-result-processor-lambda/index.test.ts create mode 100644 lambdas/src/hiv-result-processor-lambda/index.ts create mode 100644 lambdas/src/hiv-result-processor-lambda/init.test.ts create mode 100644 lambdas/src/hiv-result-processor-lambda/init.ts create mode 100644 lambdas/src/hiv-result-processor-lambda/models.ts create mode 100644 lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts create mode 100644 lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts create mode 100644 lambdas/src/hiv-result-processor-lambda/task-builder.test.ts create mode 100644 lambdas/src/hiv-result-processor-lambda/task-builder.ts create mode 100644 lambdas/src/hiv-result-processor-lambda/validation-service.test.ts create mode 100644 lambdas/src/hiv-result-processor-lambda/validation-service.ts create mode 100644 lambdas/src/hiv-result-processor-lambda/validation.ts diff --git a/lambdas/package-lock.json b/lambdas/package-lock.json index e6f3130f1..c9bcea748 100644 --- a/lambdas/package-lock.json +++ b/lambdas/package-lock.json @@ -8,6 +8,7 @@ "name": "@hometest-service/lambdas", "version": "1.0.0", "dependencies": { + "@aws-sdk/client-lambda": "^3.1031.0", "@aws-sdk/client-secrets-manager": "3.1028.0", "@aws-sdk/client-sqs": "3.1028.0", "@aws-sdk/rds-signer": "3.1028.0", @@ -49,6 +50,20 @@ "typescript-eslint": "8.58.1" } }, + "node_modules/@aws-crypto/crc32": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/@aws-crypto/crc32/-/crc32-5.2.0.tgz", + "integrity": "sha512-nLbCWqQNgUiwwtFsen1AdzAtvuLRsQS8rYgMuxCrdKf9kOssamGLuPwyTY9wyYblNr9+1XM8v6zoDTPPSIeANg==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/util": "^5.2.0", + "@aws-sdk/types": "^3.222.0", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=16.0.0" + } + }, "node_modules/@aws-crypto/sha256-browser": { "version": "5.2.0", "resolved": "https://registry.npmjs.org/@aws-crypto/sha256-browser/-/sha256-browser-5.2.0.tgz", @@ -224,6 +239,61 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-lambda": { + "version": "3.1031.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-lambda/-/client-lambda-3.1031.0.tgz", + "integrity": "sha512-ri3DCr1QvTBprrCxvcDGaMbh3YJHUuUUQpA6pCq9PbrFgmyBpznR2xzI1X1vNxB4Nu9D3bqPYzUxa+agI8+svQ==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.974.0", + "@aws-sdk/credential-provider-node": "^3.972.31", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.30", + "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.16", + "@smithy/config-resolver": "^4.4.16", + "@smithy/core": "^3.23.15", + "@smithy/eventstream-serde-browser": "^4.2.14", + "@smithy/eventstream-serde-config-resolver": "^4.3.14", + "@smithy/eventstream-serde-node": "^4.2.14", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.30", + "@smithy/middleware-retry": "^4.5.3", + "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.5.3", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.11", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-body-length-browser": "^4.2.2", + "@smithy/util-body-length-node": "^4.2.3", + "@smithy/util-defaults-mode-browser": "^4.3.47", + "@smithy/util-defaults-mode-node": "^4.2.52", + "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.2", + "@smithy/util-stream": "^4.5.23", + "@smithy/util-utf8": "^4.2.2", + "@smithy/util-waiter": "^4.2.16", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-secrets-manager": { "version": "3.1028.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-secrets-manager/-/client-secrets-manager-3.1028.0.tgz", @@ -327,22 +397,22 @@ } }, "node_modules/@aws-sdk/core": { - "version": "3.973.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.973.27.tgz", - "integrity": "sha512-CUZ5m8hwMCH6OYI4Li/WgMfIEx10Q2PLI9Y3XOUTPGZJ53aZ0007jCv+X/ywsaERyKPdw5MRZWk877roQksQ4A==", + "version": "3.974.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/core/-/core-3.974.0.tgz", + "integrity": "sha512-8j+dMtyDqNXFmi09CBdz8TY6Ltf2jhfHuP6ZvG4zVjndRc6JF0aeBUbRwQLndbptFCsdctRQgdNWecy4TIfXAw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/xml-builder": "^3.972.17", - "@smithy/core": "^3.23.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/signature-v4": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/xml-builder": "^3.972.18", + "@smithy/core": "^3.23.15", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/signature-v4": "^5.3.14", + "@smithy/smithy-client": "^4.12.11", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", - "@smithy/util-middleware": "^4.2.13", + "@smithy/util-middleware": "^4.2.14", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -367,15 +437,15 @@ } }, "node_modules/@aws-sdk/credential-provider-env": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.25.tgz", - "integrity": "sha512-6QfI0wv4jpG5CrdO/AO0JfZ2ux+tKwJPrUwmvxXF50vI5KIypKVGNF6b4vlkYEnKumDTI1NX2zUBi8JoU5QU3A==", + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-env/-/credential-provider-env-3.972.26.tgz", + "integrity": "sha512-WBHAMxyPdgeJY6ZGLvq9mJwzZ+GaNUROQbfdVshtMsDVBrZTj5ZuFjKclSjSHvKSHJ4Y4O2yvI/aA/hrJbYfng==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -383,20 +453,20 @@ } }, "node_modules/@aws-sdk/credential-provider-http": { - "version": "3.972.27", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.27.tgz", - "integrity": "sha512-3V3Usj9Gs93h865DqN4M2NWJhC5kXU9BvZskfN3+69omuYlE3TZxOEcVQtBGLOloJB7BVfJKXVLqeNhOzHqSlQ==", + "version": "3.972.28", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-http/-/credential-provider-http-3.972.28.tgz", + "integrity": "sha512-+1DwCjjpo1WoiZTN08yGitI3nUwZUSQWVWFrW4C46HqZwACjcUQ7C66tnKPBTVxrEYYDOP11A6Afmu1L6ylt3g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/types": "^3.973.7", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/property-provider": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/util-stream": "^4.5.22", + "@aws-sdk/core": "^3.974.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.5.3", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.11", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.23", "tslib": "^2.6.2" }, "engines": { @@ -404,24 +474,24 @@ } }, "node_modules/@aws-sdk/credential-provider-ini": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.29.tgz", - "integrity": "sha512-SiBuAnXecCbT/OpAf3vqyI/AVE3mTaYr9ShXLybxZiPLBiPCCOIWSGAtYYGQWMRvobBTiqOewaB+wcgMMZI2Aw==", + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-ini/-/credential-provider-ini-3.972.30.tgz", + "integrity": "sha512-Fg1oJcoijwOZjTxdbx+ubqbQl8YEQ4Cwhjw6TWzQjuDEvQYNhnCXW2pN7eKtdTrdE4a6+5TVKGSm2I+i2BKIQg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/credential-provider-env": "^3.972.25", - "@aws-sdk/credential-provider-http": "^3.972.27", - "@aws-sdk/credential-provider-login": "^3.972.29", - "@aws-sdk/credential-provider-process": "^3.972.25", - "@aws-sdk/credential-provider-sso": "^3.972.29", - "@aws-sdk/credential-provider-web-identity": "^3.972.29", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/credential-provider-imds": "^4.2.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.0", + "@aws-sdk/credential-provider-env": "^3.972.26", + "@aws-sdk/credential-provider-http": "^3.972.28", + "@aws-sdk/credential-provider-login": "^3.972.30", + "@aws-sdk/credential-provider-process": "^3.972.26", + "@aws-sdk/credential-provider-sso": "^3.972.30", + "@aws-sdk/credential-provider-web-identity": "^3.972.30", + "@aws-sdk/nested-clients": "^3.996.20", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -429,18 +499,18 @@ } }, "node_modules/@aws-sdk/credential-provider-login": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.29.tgz", - "integrity": "sha512-OGOslTbOlxXexKMqhxCEbBQbUIfuhGxU5UXw3Fm56ypXHvrXH4aTt/xb5Y884LOoteP1QST1lVZzHfcTnWhiPQ==", + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-login/-/credential-provider-login-3.972.30.tgz", + "integrity": "sha512-nchIrrI/7dgjG1bW/DEWOJc00K9n+kkl6B8Mk0KO6d4GfWBOXlVr9uHp7CJR9FIrjmov5SGjHXG2q9XAtkRw6Q==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.0", + "@aws-sdk/nested-clients": "^3.996.20", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -448,22 +518,22 @@ } }, "node_modules/@aws-sdk/credential-provider-node": { - "version": "3.972.30", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.30.tgz", - "integrity": "sha512-FMnAnWxc8PG+ZrZ2OBKzY4luCUJhe9CG0B9YwYr4pzrYGLXBS2rl+UoUvjGbAwiptxRL6hyA3lFn03Bv1TLqTw==", + "version": "3.972.31", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-node/-/credential-provider-node-3.972.31.tgz", + "integrity": "sha512-99OHVQ6eZ5DTxiOWgHdjBMvLqv7xoY4jLK6nZ1NcNSQbAnYZkQNIHi/VqInc9fnmg7of9si/z+waE6YL9OQIlw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/credential-provider-env": "^3.972.25", - "@aws-sdk/credential-provider-http": "^3.972.27", - "@aws-sdk/credential-provider-ini": "^3.972.29", - "@aws-sdk/credential-provider-process": "^3.972.25", - "@aws-sdk/credential-provider-sso": "^3.972.29", - "@aws-sdk/credential-provider-web-identity": "^3.972.29", - "@aws-sdk/types": "^3.973.7", - "@smithy/credential-provider-imds": "^4.2.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/credential-provider-env": "^3.972.26", + "@aws-sdk/credential-provider-http": "^3.972.28", + "@aws-sdk/credential-provider-ini": "^3.972.30", + "@aws-sdk/credential-provider-process": "^3.972.26", + "@aws-sdk/credential-provider-sso": "^3.972.30", + "@aws-sdk/credential-provider-web-identity": "^3.972.30", + "@aws-sdk/types": "^3.973.8", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -471,16 +541,16 @@ } }, "node_modules/@aws-sdk/credential-provider-process": { - "version": "3.972.25", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.25.tgz", - "integrity": "sha512-HR7ynNRdNhNsdVCOCegy1HsfsRzozCOPtD3RzzT1JouuaHobWyRfJzCBue/3jP7gECHt+kQyZUvwg/cYLWurNQ==", + "version": "3.972.26", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-process/-/credential-provider-process-3.972.26.tgz", + "integrity": "sha512-jibxNld3m+vbmQwn98hcQ+fLIVrx3cQuhZlSs1/hix48SjDS5/pjMLwpmtLD/lFnd6ve1AL4o1bZg3X1WRa2SQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -488,18 +558,18 @@ } }, "node_modules/@aws-sdk/credential-provider-sso": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.29.tgz", - "integrity": "sha512-HWv4SEq3jZDYPlwryZVef97+U8CxxRos5mK8sgGO1dQaFZpV5giZLzqGE5hkDmh2csYcBO2uf5XHjPTpZcJlig==", + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-sso/-/credential-provider-sso-3.972.30.tgz", + "integrity": "sha512-honYIM17F/+QSWJRE84T4u//ofqEi7rLbnwmIpu7fgFX5PML78wbtdSAy5Xwyve3TLpE9/f9zQx0aBVxSjAOPw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/token-providers": "3.1026.0", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.0", + "@aws-sdk/nested-clients": "^3.996.20", + "@aws-sdk/token-providers": "3.1031.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -507,17 +577,17 @@ } }, "node_modules/@aws-sdk/credential-provider-web-identity": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.29.tgz", - "integrity": "sha512-PdMBza1WEKEUPFEmMGCfnU2RYCz9MskU2e8JxjyUOsMKku7j9YaDKvbDi2dzC0ihFoM6ods2SbhfAAro+Gwlew==", + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/credential-provider-web-identity/-/credential-provider-web-identity-3.972.30.tgz", + "integrity": "sha512-CyL4oWUlONQRN2SsYMVrA9Z3i3QfLWTQctI8tuKbjNGCVVDCnJf/yMbSJCOZgpPFRtxh7dgQwvpqwmJm+iytmw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.0", + "@aws-sdk/nested-clients": "^3.996.20", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -556,14 +626,14 @@ } }, "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.9.tgz", - "integrity": "sha512-je5vRdNw4SkuTnmRbFZLdye4sQ0faLt8kwka5wnnSU30q1mHO4X+idGEJOOE+Tn1ME7Oryn05xxkDvIb3UaLaQ==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.10.tgz", + "integrity": "sha512-IJSsIMeVQ8MMCPbuh1AbltkFhLBLXn7aejzfX5YKT/VLDHn++Dcz8886tXckE+wQssyPUhaXrJhdakO2VilRhg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -571,13 +641,13 @@ } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.9.tgz", - "integrity": "sha512-HsVgDrruhqI28RkaXALm8grJ7Agc1wF6Et0xh6pom8NdO2VdO/SD9U/tPwUjewwK/pVoka+EShBxyCvgsPCtog==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.10.tgz", + "integrity": "sha512-OOuGvvz1Dm20SjZo5oEBePFqxt5nf8AwkNDSyUHvD9/bfNASmstcYxFAHUowy4n6Io7mWUZ04JURZwSBvyQanQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -585,15 +655,15 @@ } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.10", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.10.tgz", - "integrity": "sha512-RVQQbq5orQ/GHUnXvqEOj2HHPBJm+mM+ySwZKS5UaLBwra5ugRtiH09PLUoOZRl7a1YzaOzXSuGbn9iD5j60WQ==", + "version": "3.972.11", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.11.tgz", + "integrity": "sha512-+zz6f79Kj9V5qFK2P+D8Ehjnw4AhphAlCAsPjUqEcInA9umtSSKMrHbSagEeOIsDNuvVrH98bjRHcyQukTrhaQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", + "@aws-sdk/types": "^3.973.8", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -618,18 +688,18 @@ } }, "node_modules/@aws-sdk/middleware-user-agent": { - "version": "3.972.29", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.29.tgz", - "integrity": "sha512-f/sIRzuTfEjg6NsbMYvye2VsmnQoNgntntleQyx5uGacUYzszbfIlO3GcI6G6daWUmTm0IDZc11qMHWwF0o0mQ==", + "version": "3.972.30", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-user-agent/-/middleware-user-agent-3.972.30.tgz", + "integrity": "sha512-lCz6JfelhjD6Eco1urXM2rOYRaxROSqeoY6IEKx+soegFJOajmIBCMHTAWuJl25Wf9IAST+i0/yOk9G3rMV26A==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@smithy/core": "^3.23.14", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-retry": "^4.3.0", + "@aws-sdk/core": "^3.974.0", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.7", + "@smithy/core": "^3.23.15", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-retry": "^4.3.2", "tslib": "^2.6.2" }, "engines": { @@ -637,47 +707,47 @@ } }, "node_modules/@aws-sdk/nested-clients": { - "version": "3.996.19", - "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.19.tgz", - "integrity": "sha512-uFkmCDXvmQYLanlYdOFS0+MQWkrj9wPMt/ZCc/0J0fjPim6F5jBVBmEomvGY/j77ILW6GTPwN22Jc174Mhkw6Q==", + "version": "3.996.20", + "resolved": "https://registry.npmjs.org/@aws-sdk/nested-clients/-/nested-clients-3.996.20.tgz", + "integrity": "sha512-bzPdsNQnCh6TvvUmTHLZlL8qgyME6mNiUErcRMyJPywIl1BEu2VZRShel3mUoSh89bOBEXEWtjocDMolFxd/9A==", "license": "Apache-2.0", "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/middleware-host-header": "^3.972.9", - "@aws-sdk/middleware-logger": "^3.972.9", - "@aws-sdk/middleware-recursion-detection": "^3.972.10", - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/region-config-resolver": "^3.972.11", - "@aws-sdk/types": "^3.973.7", - "@aws-sdk/util-endpoints": "^3.996.6", - "@aws-sdk/util-user-agent-browser": "^3.972.9", - "@aws-sdk/util-user-agent-node": "^3.973.15", - "@smithy/config-resolver": "^4.4.14", - "@smithy/core": "^3.23.14", - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/hash-node": "^4.2.13", - "@smithy/invalid-dependency": "^4.2.13", - "@smithy/middleware-content-length": "^4.2.13", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-retry": "^4.5.0", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@aws-sdk/core": "^3.974.0", + "@aws-sdk/middleware-host-header": "^3.972.10", + "@aws-sdk/middleware-logger": "^3.972.10", + "@aws-sdk/middleware-recursion-detection": "^3.972.11", + "@aws-sdk/middleware-user-agent": "^3.972.30", + "@aws-sdk/region-config-resolver": "^3.972.12", + "@aws-sdk/types": "^3.973.8", + "@aws-sdk/util-endpoints": "^3.996.7", + "@aws-sdk/util-user-agent-browser": "^3.972.10", + "@aws-sdk/util-user-agent-node": "^3.973.16", + "@smithy/config-resolver": "^4.4.16", + "@smithy/core": "^3.23.15", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/hash-node": "^4.2.14", + "@smithy/invalid-dependency": "^4.2.14", + "@smithy/middleware-content-length": "^4.2.14", + "@smithy/middleware-endpoint": "^4.4.30", + "@smithy/middleware-retry": "^4.5.3", + "@smithy/middleware-serde": "^4.2.18", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/node-http-handler": "^4.5.3", + "@smithy/protocol-http": "^5.3.14", + "@smithy/smithy-client": "^4.12.11", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", - "@smithy/util-defaults-mode-browser": "^4.3.45", - "@smithy/util-defaults-mode-node": "^4.2.49", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.0", + "@smithy/util-defaults-mode-browser": "^4.3.47", + "@smithy/util-defaults-mode-node": "^4.2.52", + "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, @@ -709,15 +779,15 @@ } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.11", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.11.tgz", - "integrity": "sha512-6Q8B1dcx6BBqUTY1Mc/eROKA0FImEEY5VPSd6AGPEUf0ErjExz4snVqa9kNJSoVDV1rKaNf3qrWojgcKW+SdDg==", + "version": "3.972.12", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.12.tgz", + "integrity": "sha512-QQI43Mxd53nBij0pm8HXC+t4IOC6gnhhZfzxE0OATQyO6QfPV4e+aTIRRuAJKA6Nig/cR8eLwPryqYTX9ZrjAQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/config-resolver": "^4.4.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/config-resolver": "^4.4.16", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -725,17 +795,17 @@ } }, "node_modules/@aws-sdk/token-providers": { - "version": "3.1026.0", - "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1026.0.tgz", - "integrity": "sha512-Ieq/HiRrbEtrYP387Nes0XlR7H1pJiJOZKv+QyQzMYpvTiDs0VKy2ZB3E2Zf+aFovWmeE7lRE4lXyF7dYM6GgA==", + "version": "3.1031.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/token-providers/-/token-providers-3.1031.0.tgz", + "integrity": "sha512-zj/PvnbQK/2KJNln5K2QRI9HSsy+B4emz2gbQyUHkk6l7Lidu83P/9tfmC2cJXkcC3vdmyKH2DP3Iw/FDfKQuQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/core": "^3.973.27", - "@aws-sdk/nested-clients": "^3.996.19", - "@aws-sdk/types": "^3.973.7", - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@aws-sdk/core": "^3.974.0", + "@aws-sdk/nested-clients": "^3.996.20", + "@aws-sdk/types": "^3.973.8", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -743,12 +813,12 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.7", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.7.tgz", - "integrity": "sha512-reXRwoJ6CfChoqAsBszUYajAF8Z2LRE+CRcKocvFSMpIiLOtYU3aJ9trmn6VVPAzbbY5LXF+FfmUslbXk1SYFg==", + "version": "3.973.8", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.8.tgz", + "integrity": "sha512-gjlAdtHMbtR9X5iIhVUvbVcy55KnznpC6bkDUWW9z915bi0ckdUr5cjf16Kp6xq0bP5HBD2xzgbL9F9Quv5vUw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -756,15 +826,15 @@ } }, "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.6.tgz", - "integrity": "sha512-2nUQ+2ih7CShuKHpGSIYvvAIOHy52dOZguYG36zptBukhw6iFwcvGfG0tes0oZFWQqEWvgZe9HLWaNlvXGdOrg==", + "version": "3.996.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.7.tgz", + "integrity": "sha512-ty4LQxN1QC+YhUP28NfEgZDEGXkyqOQy+BDriBozqHsrYO4JMgiPhfizqOGF7P+euBTZ5Ez6SKlLAMCLo8tzmw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", - "@smithy/util-endpoints": "^3.3.4", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-endpoints": "^3.4.1", "tslib": "^2.6.2" }, "engines": { @@ -799,27 +869,27 @@ } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.9", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.9.tgz", - "integrity": "sha512-sn/LMzTbGjYqCCF24390WxPd6hkpoSptiUn5DzVp4cD71yqw+yGEGm1YCxyEoPXyc8qciM8UzLJcZBFslxo5Uw==", + "version": "3.972.10", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.10.tgz", + "integrity": "sha512-FAzqXvfEssGdSIz8ejatan0bOdx1qefBWKF/gWmVBXIP1HkS7v/wjjaqrAGGKvyihrXTXW00/2/1nTJtxpXz7g==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.7", - "@smithy/types": "^4.14.0", + "@aws-sdk/types": "^3.973.8", + "@smithy/types": "^4.14.1", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "node_modules/@aws-sdk/util-user-agent-node": { - "version": "3.973.15", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.15.tgz", - "integrity": "sha512-fYn3s9PtKdgQkczGZCFMgkNEe8aq1JCVbnRqjqN9RSVW43xn2RV9xdcZ3z01a48Jpkuh/xCmBKJxdLOo4Ozg7w==", + "version": "3.973.16", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-node/-/util-user-agent-node-3.973.16.tgz", + "integrity": "sha512-ccvu0FNCI0C6OqmxI/tWn7BD8qGooWuURssiIM+6vbksFO8opXR4JOGtGYPj8QYzN/vfwNYrcK344PPbYuvzRg==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/middleware-user-agent": "^3.972.29", - "@aws-sdk/types": "^3.973.7", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", + "@aws-sdk/middleware-user-agent": "^3.972.30", + "@aws-sdk/types": "^3.973.8", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", "tslib": "^2.6.2" }, @@ -836,12 +906,12 @@ } }, "node_modules/@aws-sdk/xml-builder": { - "version": "3.972.17", - "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.17.tgz", - "integrity": "sha512-Ra7hjqAZf1OXRRMueB13qex7mFJRDK/pgCvdSFemXBT8KCGnQDPoKzHY1SjN+TjJVmnpSF14W5tJ1vDamFu+Gg==", + "version": "3.972.18", + "resolved": "https://registry.npmjs.org/@aws-sdk/xml-builder/-/xml-builder-3.972.18.tgz", + "integrity": "sha512-BMDNVG1ETXRhl1tnisQiYBef3RShJ1kfZA7x7afivTFMLirfHNTb6U71K569HNXhSXbQZsweHvSDZ6euBw8hPA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "fast-xml-parser": "5.5.8", "tslib": "^2.6.2" }, @@ -4051,16 +4121,16 @@ } }, "node_modules/@smithy/config-resolver": { - "version": "4.4.14", - "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.14.tgz", - "integrity": "sha512-N55f8mPEccpzKetUagdvmAy8oohf0J5cuj9jLI1TaSceRlq0pJsIZepY3kmAXAhyxqXPV6hDerDQhqQPKWgAoQ==", + "version": "4.4.16", + "resolved": "https://registry.npmjs.org/@smithy/config-resolver/-/config-resolver-4.4.16.tgz", + "integrity": "sha512-GFlGPNLZKrGfqWpqVb31z7hvYCA9ZscfX1buYnvvMGcRYsQQnhH+4uN6mWWflcD5jB4OXP/LBrdpukEdjl41tg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-config-provider": "^4.2.2", - "@smithy/util-endpoints": "^3.3.4", - "@smithy/util-middleware": "^4.2.13", + "@smithy/util-endpoints": "^3.4.1", + "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -4068,18 +4138,18 @@ } }, "node_modules/@smithy/core": { - "version": "3.23.14", - "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.14.tgz", - "integrity": "sha512-vJ0IhpZxZAkFYOegMKSrxw7ujhhT2pass/1UEcZ4kfl5srTAqtPU5I7MdYQoreVas3204ykCiNhY1o7Xlz6Yyg==", + "version": "3.23.15", + "resolved": "https://registry.npmjs.org/@smithy/core/-/core-3.23.15.tgz", + "integrity": "sha512-E7GVCgsQttzfujEZb6Qep005wWf4xiL4x06apFEtzQMWYBPggZh/0cnOxPficw5cuK/YjjkehKoIN4YUaSh0UQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-stream": "^4.5.22", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-stream": "^4.5.23", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" @@ -4089,15 +4159,85 @@ } }, "node_modules/@smithy/credential-provider-imds": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.13.tgz", - "integrity": "sha512-wboCPijzf6RJKLOvnjDAiBxGSmSnGXj35o5ZAWKDaHa/cvQ5U3ZJ13D4tMCE8JG4dxVAZFy/P0x/V9CwwdfULQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/credential-provider-imds/-/credential-provider-imds-4.2.14.tgz", + "integrity": "sha512-Au28zBN48ZAoXdooGUHemuVBrkE+Ie6RPmGNIAJsFqj33Vhb6xAgRifUydZ2aY+M+KaMAETAlKk5NC5h1G7wpg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-codec": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-codec/-/eventstream-codec-4.2.14.tgz", + "integrity": "sha512-erZq0nOIpzfeZdCyzZjdJb4nVSKLUmSkaQUVkRGQTXs30gyUGeKnrYEg+Xe1W5gE3aReS7IgsvANwVPxSzY6Pw==", + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/crc32": "5.2.0", + "@smithy/types": "^4.14.1", + "@smithy/util-hex-encoding": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-browser": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-browser/-/eventstream-serde-browser-4.2.14.tgz", + "integrity": "sha512-8IelTCtTctWRbb+0Dcy+C0aICh1qa0qWXqgjcXDmMuCvPJRnv26hiDZoAau2ILOniki65mCPKqOQs/BaWvO4CQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-config-resolver": { + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-config-resolver/-/eventstream-serde-config-resolver-4.3.14.tgz", + "integrity": "sha512-sqHiHpYRYo3FJlaIxD1J8PhbcmJAm7IuM16mVnwSkCToD7g00IBZzKuiLNMGmftULmEUX6/UAz8/NN5uMP8bVA==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-node": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-node/-/eventstream-serde-node-4.2.14.tgz", + "integrity": "sha512-Ht/8BuGlKfFTy0H3+8eEu0vdpwGztCnaLLXtpXNdQqiR7Hj4vFScU3T436vRAjATglOIPjJXronY+1WxxNLSiw==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-serde-universal": "^4.2.14", + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, + "node_modules/@smithy/eventstream-serde-universal": { + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/eventstream-serde-universal/-/eventstream-serde-universal-4.2.14.tgz", + "integrity": "sha512-lWyt4T2XQZUZgK3tQ3Wn0w3XBvZsK/vjTuJl6bXbnGZBHH0ZUSONTYiK9TgjTTzU54xQr3DRFwpjmhp0oLm3gg==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/eventstream-codec": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4105,14 +4245,14 @@ } }, "node_modules/@smithy/fetch-http-handler": { - "version": "5.3.16", - "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.16.tgz", - "integrity": "sha512-nYDRUIvNd4mFmuXraRWt6w5UsZTNqtj4hXJA/iiOD4tuseIdLP9Lq38teH/SZTcIFCa2f+27o7hYpIsWktJKEQ==", + "version": "5.3.17", + "resolved": "https://registry.npmjs.org/@smithy/fetch-http-handler/-/fetch-http-handler-5.3.17.tgz", + "integrity": "sha512-bXOvQzaSm6MnmLaWA1elgfQcAtN4UP3vXqV97bHuoOrHQOJiLT3ds6o9eo5bqd0TJfRFpzdGnDQdW3FACiAVdw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/querystring-builder": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" }, @@ -4121,12 +4261,12 @@ } }, "node_modules/@smithy/hash-node": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.13.tgz", - "integrity": "sha512-4/oy9h0jjmY80a2gOIo75iLl8TOPhmtx4E2Hz+PfMjvx/vLtGY4TMU/35WRyH2JHPfT5CVB38u4JRow7gnmzJA==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/hash-node/-/hash-node-4.2.14.tgz", + "integrity": "sha512-8ZBDY2DD4wr+GGjTpPtiglEsqr0lUP+KHqgZcWczFf6qeZ/YRjMIOoQWVQlmwu7EtxKTd8YXD8lblmYcpBIA1g==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -4136,12 +4276,12 @@ } }, "node_modules/@smithy/invalid-dependency": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.13.tgz", - "integrity": "sha512-jvC0RB/8BLj2SMIkY0Npl425IdnxZJxInpZJbu563zIRnVjpDMXevU3VMCRSabaLB0kf/eFIOusdGstrLJ8IDg==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/invalid-dependency/-/invalid-dependency-4.2.14.tgz", + "integrity": "sha512-c21qJiTSb25xvvOp+H2TNZzPCngrvl5vIPqPB8zQ/DmJF4QWXO19x1dWfMJZ6wZuuWUPPm0gV8C0cU3+ifcWuw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4175,13 +4315,13 @@ } }, "node_modules/@smithy/middleware-content-length": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.13.tgz", - "integrity": "sha512-IPMLm/LE4AZwu6qiE8Rr8vJsWhs9AtOdySRXrOM7xnvclp77Tyh7hMs/FRrMf26kgIe67vFJXXOSmVxS7oKeig==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-content-length/-/middleware-content-length-4.2.14.tgz", + "integrity": "sha512-xhHq7fX4/3lv5NHxLUk3OeEvl0xZ+Ek3qIbWaCL4f9JwgDZEclPBElljaZCAItdGPQl/kSM4LPMOpy1MYgprpw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4189,18 +4329,18 @@ } }, "node_modules/@smithy/middleware-endpoint": { - "version": "4.4.29", - "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.29.tgz", - "integrity": "sha512-R9Q/58U+qBiSARGWbAbFLczECg/RmysRksX6Q8BaQEpt75I7LI6WGDZnjuC9GXSGKljEbA7N118LhGaMbfrTXw==", + "version": "4.4.30", + "resolved": "https://registry.npmjs.org/@smithy/middleware-endpoint/-/middleware-endpoint-4.4.30.tgz", + "integrity": "sha512-qS2XqhKeXmdZ4nEQ4cOxIczSP/Y91wPAHYuRwmWDCh975B7/57uxsm5d6sisnUThn2u2FwzMdJNM7AbO1YPsPg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/middleware-serde": "^4.2.17", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", - "@smithy/url-parser": "^4.2.13", - "@smithy/util-middleware": "^4.2.13", + "@smithy/core": "^3.23.15", + "@smithy/middleware-serde": "^4.2.18", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", + "@smithy/url-parser": "^4.2.14", + "@smithy/util-middleware": "^4.2.14", "tslib": "^2.6.2" }, "engines": { @@ -4208,19 +4348,19 @@ } }, "node_modules/@smithy/middleware-retry": { - "version": "4.5.1", - "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.1.tgz", - "integrity": "sha512-/zY+Gp7Qj2D2hVm3irkCyONER7E9MiX3cUUm/k2ZmhkzZkrPgwVS4aJ5NriZUEN/M0D1hhjrgjUmX04HhRwdWA==", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/@smithy/middleware-retry/-/middleware-retry-4.5.3.tgz", + "integrity": "sha512-TE8dJNi6JuxzGSxMCVd3i9IEWDndCl3bmluLsBNDWok8olgj65OfkndMhl9SZ7m14c+C5SQn/PcUmrDl57rSFw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/service-error-classification": "^4.2.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", - "@smithy/util-middleware": "^4.2.13", - "@smithy/util-retry": "^4.3.1", + "@smithy/core": "^3.23.15", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/service-error-classification": "^4.2.14", + "@smithy/smithy-client": "^4.12.11", + "@smithy/types": "^4.14.1", + "@smithy/util-middleware": "^4.2.14", + "@smithy/util-retry": "^4.3.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" }, @@ -4229,14 +4369,14 @@ } }, "node_modules/@smithy/middleware-serde": { - "version": "4.2.17", - "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.17.tgz", - "integrity": "sha512-0T2mcaM6v9W1xku86Dk0bEW7aEseG6KenFkPK98XNw0ZhOqOiD1MrMsdnQw9QsL3/Oa85T53iSMlm0SZdSuIEQ==", + "version": "4.2.18", + "resolved": "https://registry.npmjs.org/@smithy/middleware-serde/-/middleware-serde-4.2.18.tgz", + "integrity": "sha512-M6CSgnp3v4tYz9ynj2JHbA60woBZcGqEwNjTKjBsNHPV26R1ZX52+0wW8WsZU18q45jD0tw2wL22S17Ze9LpEw==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@smithy/core": "^3.23.15", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4244,12 +4384,12 @@ } }, "node_modules/@smithy/middleware-stack": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.13.tgz", - "integrity": "sha512-g72jN/sGDLyTanrCLH9fhg3oysO3f7tQa6eWWsMyn2BiYNCgjF24n4/I9wff/5XidFvjj9ilipAoQrurTUrLvw==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/middleware-stack/-/middleware-stack-4.2.14.tgz", + "integrity": "sha512-2dvkUKLuFdKsCRmOE4Mn63co0Djtsm+JMh0bYZQupN1pJwMeE8FmQmRLLzzEMN0dnNi7CDCYYH8F0EVwWiPBeA==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4257,14 +4397,14 @@ } }, "node_modules/@smithy/node-config-provider": { - "version": "4.3.13", - "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.13.tgz", - "integrity": "sha512-iGxQ04DsKXLckbgnX4ipElrOTk+IHgTyu0q0WssZfYhDm9CQWHmu6cOeI5wmWRxpXbBDhIIfXMWz5tPEtcVqbw==", + "version": "4.3.14", + "resolved": "https://registry.npmjs.org/@smithy/node-config-provider/-/node-config-provider-4.3.14.tgz", + "integrity": "sha512-S+gFjyo/weSVL0P1b9Ts8C/CwIfNCgUPikk3sl6QVsfE/uUuO+QsF+NsE/JkpvWqqyz1wg7HFdiaZuj5CoBMRg==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.13", - "@smithy/shared-ini-file-loader": "^4.4.8", - "@smithy/types": "^4.14.0", + "@smithy/property-provider": "^4.2.14", + "@smithy/shared-ini-file-loader": "^4.4.9", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4272,14 +4412,14 @@ } }, "node_modules/@smithy/node-http-handler": { - "version": "4.5.2", - "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.2.tgz", - "integrity": "sha512-/oD7u8M0oj2ZTFw7GkuuHWpIxtWdLlnyNkbrWcyVYhd5RJNDuczdkb0wfnQICyNFrVPlr8YHOhamjNy3zidhmA==", + "version": "4.5.3", + "resolved": "https://registry.npmjs.org/@smithy/node-http-handler/-/node-http-handler-4.5.3.tgz", + "integrity": "sha512-lc5jFL++x17sPhIwMWJ3YOnqmSjw/2Po6VLDlUIXvxVWRuJwRXnJ4jOBBLB0cfI5BB5ehIl02Fxr1PDvk/kxDw==", "license": "Apache-2.0", "dependencies": { - "@smithy/protocol-http": "^5.3.13", - "@smithy/querystring-builder": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/querystring-builder": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4287,12 +4427,12 @@ } }, "node_modules/@smithy/property-provider": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.13.tgz", - "integrity": "sha512-bGzUCthxRmezuxkbu9wD33wWg9KX3hJpCXpQ93vVkPrHn9ZW6KNNdY5xAUWNuRCwQ+VyboFuWirG1lZhhkcyRQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/property-provider/-/property-provider-4.2.14.tgz", + "integrity": "sha512-WuM31CgfsnQ/10i7NYr0PyxqknD72Y5uMfUMVSniPjbEPceiTErb4eIqJQ+pdxNEAUEWrewrGjIRjVbVHsxZiQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4300,12 +4440,12 @@ } }, "node_modules/@smithy/protocol-http": { - "version": "5.3.13", - "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.13.tgz", - "integrity": "sha512-+HsmuJUF4u8POo6s8/a2Yb/AQ5t/YgLovCuHF9oxbocqv+SZ6gd8lC2duBFiCA/vFHoHQhoq7QjqJqZC6xOxxg==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/protocol-http/-/protocol-http-5.3.14.tgz", + "integrity": "sha512-dN5F8kHx8RNU0r+pCwNmFZyz6ChjMkzShy/zup6MtkRmmix4vZzJdW+di7x//b1LiynIev88FM18ie+wwPcQtQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4313,12 +4453,12 @@ } }, "node_modules/@smithy/querystring-builder": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.13.tgz", - "integrity": "sha512-tG4aOYFCZdPMjbgfhnIQ322H//ojujldp1SrHPHpBSb3NqgUp3dwiUGRJzie87hS1DYwWGqDuPaowoDF+rYCbQ==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-builder/-/querystring-builder-4.2.14.tgz", + "integrity": "sha512-XYA5Z0IqTeF+5XDdh4BBmSA0HvbgVZIyv4cmOoUheDNR57K1HgBp9ukUMx3Cr3XpDHHpLBnexPE3LAtDsZkj2A==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" }, @@ -4327,12 +4467,12 @@ } }, "node_modules/@smithy/querystring-parser": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.13.tgz", - "integrity": "sha512-hqW3Q4P+CDzUyQ87GrboGMeD7XYNMOF+CuTwu936UQRB/zeYn3jys8C3w+wMkDfY7CyyyVwZQ5cNFoG0x1pYmA==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/querystring-parser/-/querystring-parser-4.2.14.tgz", + "integrity": "sha512-hr+YyqBD23GVvRxGGrcc/oOeNlK3PzT5Fu4dzrDXxzS1LpFiuL2PQQqKPs87M79aW7ziMs+nvB3qdw77SqE7Lw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4340,24 +4480,24 @@ } }, "node_modules/@smithy/service-error-classification": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.13.tgz", - "integrity": "sha512-a0s8XZMfOC/qpqq7RCPvJlk93rWFrElH6O++8WJKz0FqnA4Y7fkNi/0mnGgSH1C4x6MFsuBA8VKu4zxFrMe5Vw==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/service-error-classification/-/service-error-classification-4.2.14.tgz", + "integrity": "sha512-vVimoUnGxlx4eLLQbZImdOZFOe+Zh+5ACntv8VxZuGP72LdWu5GV3oEmCahSEReBgRJoWjypFkrehSj7BWx1HQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0" + "@smithy/types": "^4.14.1" }, "engines": { "node": ">=18.0.0" } }, "node_modules/@smithy/shared-ini-file-loader": { - "version": "4.4.8", - "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.8.tgz", - "integrity": "sha512-VZCZx2bZasxdqxVgEAhREvDSlkatTPnkdWy1+Kiy8w7kYPBosW0V5IeDwzDUMvWBt56zpK658rx1cOBFOYaPaw==", + "version": "4.4.9", + "resolved": "https://registry.npmjs.org/@smithy/shared-ini-file-loader/-/shared-ini-file-loader-4.4.9.tgz", + "integrity": "sha512-495/V2I15SHgedSJoDPD23JuSfKAp726ZI1V0wtjB07Wh7q/0tri/0e0DLefZCHgxZonrGKt/OCTpAtP1wE1kQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4365,16 +4505,16 @@ } }, "node_modules/@smithy/signature-v4": { - "version": "5.3.13", - "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.13.tgz", - "integrity": "sha512-YpYSyM0vMDwKbHD/JA7bVOF6kToVRpa+FM5ateEVRpsTNu564g1muBlkTubXhSKKYXInhpADF46FPyrZcTLpXg==", + "version": "5.3.14", + "resolved": "https://registry.npmjs.org/@smithy/signature-v4/-/signature-v4-5.3.14.tgz", + "integrity": "sha512-1D9Y/nmlVjCeSivCbhZ7hgEpmHyY1h0GvpSZt3l0xcD9JjmjVC1CHOozS6+Gh+/ldMH8JuJ6cujObQqfayAVFA==", "license": "Apache-2.0", "dependencies": { "@smithy/is-array-buffer": "^4.2.2", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", "@smithy/util-hex-encoding": "^4.2.2", - "@smithy/util-middleware": "^4.2.13", + "@smithy/util-middleware": "^4.2.14", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" @@ -4384,17 +4524,17 @@ } }, "node_modules/@smithy/smithy-client": { - "version": "4.12.9", - "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.9.tgz", - "integrity": "sha512-ovaLEcTU5olSeHcRXcxV6viaKtpkHZumn6Ps0yn7dRf2rRSfy794vpjOtrWDO0d1auDSvAqxO+lyhERSXQ03EQ==", + "version": "4.12.11", + "resolved": "https://registry.npmjs.org/@smithy/smithy-client/-/smithy-client-4.12.11.tgz", + "integrity": "sha512-wzz/Wa1CH/Tlhxh0s4DQPEcXSxSVfJ59AZcUh9Gu0c6JTlKuwGf4o/3P2TExv0VbtPFt8odIBG+eQGK2+vTECg==", "license": "Apache-2.0", "dependencies": { - "@smithy/core": "^3.23.14", - "@smithy/middleware-endpoint": "^4.4.29", - "@smithy/middleware-stack": "^4.2.13", - "@smithy/protocol-http": "^5.3.13", - "@smithy/types": "^4.14.0", - "@smithy/util-stream": "^4.5.22", + "@smithy/core": "^3.23.15", + "@smithy/middleware-endpoint": "^4.4.30", + "@smithy/middleware-stack": "^4.2.14", + "@smithy/protocol-http": "^5.3.14", + "@smithy/types": "^4.14.1", + "@smithy/util-stream": "^4.5.23", "tslib": "^2.6.2" }, "engines": { @@ -4402,9 +4542,9 @@ } }, "node_modules/@smithy/types": { - "version": "4.14.0", - "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.0.tgz", - "integrity": "sha512-OWgntFLW88kx2qvf/c/67Vno1yuXm/f9M7QFAtVkkO29IJXGBIg0ycEaBTH0kvCtwmvZxRujrgP5a86RvsXJAQ==", + "version": "4.14.1", + "resolved": "https://registry.npmjs.org/@smithy/types/-/types-4.14.1.tgz", + "integrity": "sha512-59b5HtSVrVR/eYNei3BUj3DCPKD/G7EtDDe7OEJE7i7FtQFugYo6MxbotS8mVJkLNVf8gYaAlEBwwtJ9HzhWSg==", "license": "Apache-2.0", "dependencies": { "tslib": "^2.6.2" @@ -4414,13 +4554,13 @@ } }, "node_modules/@smithy/url-parser": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.13.tgz", - "integrity": "sha512-2G03yoboIRZlZze2+PT4GZEjgwQsJjUgn6iTsvxA02bVceHR6vp4Cuk7TUnPFWKF+ffNUk3kj4COwkENS2K3vw==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/url-parser/-/url-parser-4.2.14.tgz", + "integrity": "sha512-p06BiBigJ8bTA3MgnOfCtDUWnAMY0YfedO/GRpmc7p+wg3KW8vbXy1xwSu5ASy0wV7rRYtlfZOIKH4XqfhjSQQ==", "license": "Apache-2.0", "dependencies": { - "@smithy/querystring-parser": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/querystring-parser": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4491,14 +4631,14 @@ } }, "node_modules/@smithy/util-defaults-mode-browser": { - "version": "4.3.45", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.45.tgz", - "integrity": "sha512-ag9sWc6/nWZAuK3Wm9KlFJUnRkXLrXn33RFjIAmCTFThqLHY+7wCst10BGq56FxslsDrjhSie46c8OULS+BiIw==", + "version": "4.3.47", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-browser/-/util-defaults-mode-browser-4.3.47.tgz", + "integrity": "sha512-zlIuXai3/SHjQUQ8y3g/woLvrH573SK2wNjcDaHu5e9VOcC0JwM1MI0Sq0GZJyN3BwSUneIhpjZ18nsiz5AtQw==", "license": "Apache-2.0", "dependencies": { - "@smithy/property-provider": "^4.2.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@smithy/property-provider": "^4.2.14", + "@smithy/smithy-client": "^4.12.11", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4506,17 +4646,17 @@ } }, "node_modules/@smithy/util-defaults-mode-node": { - "version": "4.2.49", - "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.49.tgz", - "integrity": "sha512-jlN6vHwE8gY5AfiFBavtD3QtCX2f7lM3BKkz7nFKSNfFR5nXLXLg6sqXTJEEyDwtxbztIDBQCfjsGVXlIru2lQ==", + "version": "4.2.52", + "resolved": "https://registry.npmjs.org/@smithy/util-defaults-mode-node/-/util-defaults-mode-node-4.2.52.tgz", + "integrity": "sha512-cQBz8g68Vnw1W2meXlkb3D/hXJU+Taiyj9P8qLJtjREEV9/Td65xi4A/H1sRQ8EIgX5qbZbvdYPKygKLholZ3w==", "license": "Apache-2.0", "dependencies": { - "@smithy/config-resolver": "^4.4.14", - "@smithy/credential-provider-imds": "^4.2.13", - "@smithy/node-config-provider": "^4.3.13", - "@smithy/property-provider": "^4.2.13", - "@smithy/smithy-client": "^4.12.9", - "@smithy/types": "^4.14.0", + "@smithy/config-resolver": "^4.4.16", + "@smithy/credential-provider-imds": "^4.2.14", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/property-provider": "^4.2.14", + "@smithy/smithy-client": "^4.12.11", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4524,13 +4664,13 @@ } }, "node_modules/@smithy/util-endpoints": { - "version": "3.3.4", - "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.3.4.tgz", - "integrity": "sha512-BKoR/ubPp9KNKFxPpg1J28N1+bgu8NGAtJblBP7yHy8yQPBWhIAv9+l92SlQLpolGm71CVO+btB60gTgzT0wog==", + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/@smithy/util-endpoints/-/util-endpoints-3.4.1.tgz", + "integrity": "sha512-wMxNDZJrgS5mQV9oxCs4TWl5767VMgOfqfZ3JHyCkMtGC2ykW9iPqMvFur695Otcc5yxLG8OKO/80tsQBxrhXg==", "license": "Apache-2.0", "dependencies": { - "@smithy/node-config-provider": "^4.3.13", - "@smithy/types": "^4.14.0", + "@smithy/node-config-provider": "^4.3.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4550,12 +4690,12 @@ } }, "node_modules/@smithy/util-middleware": { - "version": "4.2.13", - "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.13.tgz", - "integrity": "sha512-GTooyrlmRTqvUen4eK7/K1p6kryF7bnDfq6XsAbIsf2mo51B/utaH+XThY6dKgNCWzMAaH/+OLmqaBuLhLWRow==", + "version": "4.2.14", + "resolved": "https://registry.npmjs.org/@smithy/util-middleware/-/util-middleware-4.2.14.tgz", + "integrity": "sha512-1Su2vj9RYNDEv/V+2E+jXkkwGsgR7dc4sfHn9Z7ruzQHJIEni9zzw5CauvRXlFJfmgcqYP8fWa0dkh2Q2YaQyw==", "license": "Apache-2.0", "dependencies": { - "@smithy/types": "^4.14.0", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4563,13 +4703,13 @@ } }, "node_modules/@smithy/util-retry": { - "version": "4.3.1", - "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.1.tgz", - "integrity": "sha512-FwmicpgWOkP5kZUjN3y+3JIom8NLGqSAJBeoIgK0rIToI817TEBHCrd0A2qGeKQlgDeP+Jzn4i0H/NLAXGy9uQ==", + "version": "4.3.2", + "resolved": "https://registry.npmjs.org/@smithy/util-retry/-/util-retry-4.3.2.tgz", + "integrity": "sha512-2+KTsJEwTi63NUv4uR9IQ+IFT1yu6Rf6JuoBK2WKaaJ/TRvOiOVGcXAsEqX/TQN2thR9yII21kPUJq1UV/WI2A==", "license": "Apache-2.0", "dependencies": { - "@smithy/service-error-classification": "^4.2.13", - "@smithy/types": "^4.14.0", + "@smithy/service-error-classification": "^4.2.14", + "@smithy/types": "^4.14.1", "tslib": "^2.6.2" }, "engines": { @@ -4577,14 +4717,14 @@ } }, "node_modules/@smithy/util-stream": { - "version": "4.5.22", - "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.22.tgz", - "integrity": "sha512-3H8iq/0BfQjUs2/4fbHZ9aG9yNzcuZs24LPkcX1Q7Z+qpqaGM8+qbGmE8zo9m2nCRgamyvS98cHdcWvR6YUsew==", + "version": "4.5.23", + "resolved": "https://registry.npmjs.org/@smithy/util-stream/-/util-stream-4.5.23.tgz", + "integrity": "sha512-N6on1+ngJ3RznZOnDWNveIwnTSlqxNnXuNAh7ez889ZZaRdXoNRTXKgmYOLe6dB0gCmAVtuRScE1hymQFl4hpg==", "license": "Apache-2.0", "dependencies": { - "@smithy/fetch-http-handler": "^5.3.16", - "@smithy/node-http-handler": "^4.5.2", - "@smithy/types": "^4.14.0", + "@smithy/fetch-http-handler": "^5.3.17", + "@smithy/node-http-handler": "^4.5.3", + "@smithy/types": "^4.14.1", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", @@ -4620,6 +4760,19 @@ "node": ">=18.0.0" } }, + "node_modules/@smithy/util-waiter": { + "version": "4.2.16", + "resolved": "https://registry.npmjs.org/@smithy/util-waiter/-/util-waiter-4.2.16.tgz", + "integrity": "sha512-GtclrKoZ3Lt7jPQ7aTIYKfjY92OgceScftVnkTsG8e1KV8rkvZgN+ny6YSRhd9hxB8rZtwVbmln7NTvE5O3GmQ==", + "license": "Apache-2.0", + "dependencies": { + "@smithy/types": "^4.14.1", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=18.0.0" + } + }, "node_modules/@smithy/uuid": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/@smithy/uuid/-/uuid-1.1.2.tgz", @@ -7224,9 +7377,9 @@ "license": "MIT" }, "node_modules/fast-xml-builder": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.4.tgz", - "integrity": "sha512-f2jhpN4Eccy0/Uz9csxh3Nu6q4ErKxf0XIsasomfOihuSUa3/xw6w8dnOtCDgEItQFJG8KyXPzQXzcODDrrbOg==", + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/fast-xml-builder/-/fast-xml-builder-1.1.5.tgz", + "integrity": "sha512-4TJn/8FKLeslLAH3dnohXqE3QSoxkhvaMzepOIZytwJXZO69Bfz0HBdDHzOTOon6G59Zrk6VQ2bEiv1t61rfkA==", "funding": [ { "type": "github", diff --git a/lambdas/package.json b/lambdas/package.json index 8cb2f4086..e995d58fe 100644 --- a/lambdas/package.json +++ b/lambdas/package.json @@ -20,6 +20,7 @@ "check-typescript": "tsc --noEmit" }, "dependencies": { + "@aws-sdk/client-lambda": "^3.1031.0", "@aws-sdk/client-secrets-manager": "3.1028.0", "@aws-sdk/client-sqs": "3.1028.0", "@aws-sdk/rds-signer": "3.1028.0", diff --git a/lambdas/src/hiv-result-processor-lambda/index.test.ts b/lambdas/src/hiv-result-processor-lambda/index.test.ts new file mode 100644 index 000000000..656218994 --- /dev/null +++ b/lambdas/src/hiv-result-processor-lambda/index.test.ts @@ -0,0 +1,111 @@ +import { APIGatewayProxyEvent } from "aws-lambda"; + +import { createFhirErrorResponse, createFhirResponse } from "../lib/fhir-response"; +import { handler } from "./index"; +import { InterpretationCode } from "./models"; + +// --- Mock init.ts --- +jest.mock("./init", () => { + const initMock = { + commons: { + logInfo: jest.fn(), + logError: jest.fn(), + }, + resultStatusLambdaService: { + sendTask: jest.fn(), + }, + }; + return { + init: () => initMock, + initMock, + }; +}); + +// --- Mock task-builder.ts --- +jest.mock("./task-builder", () => ({ + buildTaskFromObservation: jest.fn(() => ({ mockTask: true })), +})); + +// --- Mock validation-service.ts --- +jest.mock("./validation-service", () => ({ + extractInterpretationCodeFromFHIRObservation: jest.fn(), +})); + +// --- Mock FHIR response helpers --- +jest.mock("../lib/fhir-response", () => ({ + createFhirErrorResponse: jest.fn((code, type, message, severity) => ({ + statusCode: code, + body: JSON.stringify({ + issue: [{ code: type, diagnostics: message, severity }], + }), + })), + createFhirResponse: jest.fn((code, resource) => ({ + statusCode: code, + body: JSON.stringify(resource), + })), +})); + +const { initMock } = jest.requireMock("./init"); +const { buildTaskFromObservation } = jest.requireMock("./task-builder"); +const { extractInterpretationCodeFromFHIRObservation } = jest.requireMock("./validation-service"); + +describe("hiv-results-processor handler", () => { + const observation = { resourceType: "Observation" }; + + const event: APIGatewayProxyEvent = { + path: "/hiv", + httpMethod: "POST", + body: JSON.stringify(observation), + headers: {}, + isBase64Encoded: false, + multiValueHeaders: {}, + multiValueQueryStringParameters: null, + queryStringParameters: null, + pathParameters: null, + stageVariables: null, + requestContext: {} as any, + resource: "", + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("returns 400 for invalid JSON", async () => { + const badEvent = { ...event, body: "{invalid json" }; + + const res = await handler(badEvent as any); + + expect(res.statusCode).toBe(400); + expect(createFhirErrorResponse).toHaveBeenCalled(); + }); + + it("returns 200 and ignores reactive results", async () => { + extractInterpretationCodeFromFHIRObservation.mockReturnValue(InterpretationCode.Abnormal); + + const res = await handler(event); + + expect(res.statusCode).toBe(200); + expect(initMock.resultStatusLambdaService.sendTask).not.toHaveBeenCalled(); + }); + + it("builds task and calls status lambda for negative results", async () => { + extractInterpretationCodeFromFHIRObservation.mockReturnValue(InterpretationCode.Normal); + + const res = await handler(event); + + expect(buildTaskFromObservation).toHaveBeenCalledWith(observation); + expect(initMock.resultStatusLambdaService.sendTask).toHaveBeenCalledWith({ mockTask: true }); + expect(res.statusCode).toBe(200); + }); + + it("returns 500 if status lambda invocation fails", async () => { + extractInterpretationCodeFromFHIRObservation.mockReturnValue(InterpretationCode.Normal); + initMock.resultStatusLambdaService.sendTask.mockRejectedValueOnce(new Error("fail")); + + const res = await handler(event); + + expect(res.statusCode).toBe(500); + expect(createFhirErrorResponse).toHaveBeenCalled(); + }); +}); diff --git a/lambdas/src/hiv-result-processor-lambda/index.ts b/lambdas/src/hiv-result-processor-lambda/index.ts new file mode 100644 index 000000000..04b067ae4 --- /dev/null +++ b/lambdas/src/hiv-result-processor-lambda/index.ts @@ -0,0 +1,51 @@ +import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; +import { Observation } from "fhir/r4"; + +import { createFhirErrorResponse, createFhirResponse } from "../lib/fhir-response"; +import { init } from "./init"; +import { InterpretationCode } from "./models"; +import { buildTaskFromObservation } from "./task-builder"; +import { extractInterpretationCodeFromFHIRObservation } from "./validation-service"; + +const { commons, resultStatusLambdaService } = init(); + +export const handler = async (event: APIGatewayProxyEvent): Promise => { + commons.logInfo("hiv-results-processor", "Received HIV result", { + path: event.path, + method: event.httpMethod, + }); + + // 1. Parse Observation directly (no validation) + let observation: Observation; + try { + observation = JSON.parse(event.body ?? ""); + } catch (error) { + commons.logError("hiv-results-processor", "Invalid JSON in request body", { error }); + return createFhirErrorResponse(400, "invalid", "Invalid JSON body", "error"); + } + + // 2. Extract interpretation code ("N" or "A") + const interpretation = extractInterpretationCodeFromFHIRObservation(observation); + + // 3. If reactive (A) → ignore + if (interpretation === InterpretationCode.Abnormal) { + commons.logInfo("hiv-results-processor", "Reactive result ignored"); + return createFhirResponse(200, observation); + } + + // 4. If negative (N) → build Task + send to status lambda + if (interpretation === InterpretationCode.Normal) { + try { + const taskPayload = buildTaskFromObservation(observation); + await resultStatusLambdaService.sendTask(taskPayload); + + return createFhirResponse(200, observation); + } catch (error) { + commons.logError("hiv-results-processor", "Failed to send task to status lambda", { error }); + return createFhirErrorResponse(500, "exception", "Status update failed", "fatal"); + } + } + + // 5. Fallback (should not happen) + return createFhirResponse(200, observation); +}; diff --git a/lambdas/src/hiv-result-processor-lambda/init.test.ts b/lambdas/src/hiv-result-processor-lambda/init.test.ts new file mode 100644 index 000000000..f101d41e8 --- /dev/null +++ b/lambdas/src/hiv-result-processor-lambda/init.test.ts @@ -0,0 +1,80 @@ +import { ConsoleCommons } from "../lib/commons"; +import { buildEnvironment as init } from "./init"; +import { ResultStatusLambdaService } from "./result-status-lambda-service"; + +jest.mock("../lib/commons"); +jest.mock("./result-status-lambda-service"); + +describe("hiv-results-processor init", () => { + const originalEnv = process.env; + + beforeEach(() => { + jest.clearAllMocks(); + process.env = { ...originalEnv }; + + process.env.AWS_REGION = "eu-west-2"; + process.env.RESULT_STATUS_LAMBDA_NAME = "status-lambda"; + }); + + afterEach(() => { + process.env = originalEnv; + }); + + it("initializes commons and resultStatusLambdaService", () => { + const env = init(); + + expect(env.commons).toBeInstanceOf(ConsoleCommons); + expect(env.resultStatusLambdaService).toBeInstanceOf(ResultStatusLambdaService); + }); + + it("passes AWS_REGION and RESULT_STATUS_LAMBDA_NAME to ResultStatusLambdaService", () => { + init(); + + expect(ResultStatusLambdaService).toHaveBeenCalledWith("status-lambda", "eu-west-2"); + }); + + it("throws if AWS_REGION is missing", () => { + delete process.env.AWS_REGION; + + expect(() => init()).toThrow("Missing value for an environment variable AWS_REGION"); + }); + + it("throws if RESULT_STATUS_LAMBDA_NAME is missing", () => { + delete process.env.RESULT_STATUS_LAMBDA_NAME; + + expect(() => init()).toThrow( + "Missing value for an environment variable RESULT_STATUS_LAMBDA_NAME", + ); + }); + + it("returns the same instance on repeated calls (singleton)", () => { + jest.isolateModules(() => { + const { init: isolatedInit } = require("./init"); + + const env1 = isolatedInit(); + const env2 = isolatedInit(); + + expect(env1).toBe(env2); + expect(ResultStatusLambdaService).toHaveBeenCalledTimes(1); + }); + }); + + it("allows retry if buildEnvironment throws the first time", () => { + jest.isolateModules(() => { + jest.clearAllMocks(); + + // First call: throw + (ResultStatusLambdaService as jest.Mock).mockImplementationOnce(() => { + throw new Error("Boom"); + }); + + const { init: isolatedInit } = require("./init"); + + expect(() => isolatedInit()).toThrow("Boom"); + + // Second call: should succeed + const env = isolatedInit(); + expect(env).toBeTruthy(); + }); + }); +}); diff --git a/lambdas/src/hiv-result-processor-lambda/init.ts b/lambdas/src/hiv-result-processor-lambda/init.ts new file mode 100644 index 000000000..57437acb1 --- /dev/null +++ b/lambdas/src/hiv-result-processor-lambda/init.ts @@ -0,0 +1,32 @@ +import { Commons, ConsoleCommons } from "../lib/commons"; +import { retrieveMandatoryEnvVariable } from "../lib/utils/utils"; +import { ResultStatusLambdaService } from "./result-status-lambda-service"; + +export interface Environment { + commons: Commons; + resultStatusLambdaService: ResultStatusLambdaService; +} + +export function buildEnvironment(): Environment { + const commons = new ConsoleCommons(); + + const awsRegion = retrieveMandatoryEnvVariable("AWS_REGION"); + const resultStatusLambdaName = retrieveMandatoryEnvVariable("RESULT_STATUS_LAMBDA_NAME"); + + const resultStatusLambdaService = new ResultStatusLambdaService( + resultStatusLambdaName, + awsRegion, + ); + + return { + commons, + resultStatusLambdaService, + }; +} + +let _env: Environment | undefined; + +export function init(): Environment { + _env ??= buildEnvironment(); + return _env; +} diff --git a/lambdas/src/hiv-result-processor-lambda/models.ts b/lambdas/src/hiv-result-processor-lambda/models.ts new file mode 100644 index 000000000..499599bf0 --- /dev/null +++ b/lambdas/src/hiv-result-processor-lambda/models.ts @@ -0,0 +1,48 @@ +import { z } from "zod"; + +import { + FHIRCodeableConceptSchema, + FHIRObservationSchema, + FHIRReferenceSchema, +} from "../lib/models/fhir/fhir-schemas"; +import { ResultStatus } from "../lib/types/status"; + +export enum InterpretationCode { + Normal = "N", + Abnormal = "A", +} + +export const resultCodeMapping: { + [key in InterpretationCode]: string; +} = { + [InterpretationCode.Normal]: ResultStatus.Result_Available, + [InterpretationCode.Abnormal]: ResultStatus.Result_Withheld, +}; + +export interface Identifiers { + orderUid: string; + patientId: string; + supplierId: string; + correlationId: string; +} + +// Apply business logic specific to order results on top of schema: +// remove optionality for fields we require and only accept status of 'final' +const orderResultInterpretationCodingSchema = FHIRCodeableConceptSchema.extend({ + coding: z + .array( + z.object({ + code: z.enum(["N", "A"]), + }), + ) + .min(1), +}); + +export const orderResultFHIRObservationSchema = FHIRObservationSchema.extend({ + basedOn: z.array(FHIRReferenceSchema), + status: z.literal("final"), + subject: FHIRReferenceSchema, + performer: z.array(FHIRReferenceSchema), + valueCodeableConcept: FHIRCodeableConceptSchema, + interpretation: z.array(orderResultInterpretationCodingSchema), +}); diff --git a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts b/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts new file mode 100644 index 000000000..af67f27f3 --- /dev/null +++ b/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts @@ -0,0 +1,95 @@ +import { InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda"; +import { Task } from "fhir/r4"; + +import { ResultStatusLambdaService } from "./result-status-lambda-service"; + +const mockSend = jest.fn(); + +jest.mock("@aws-sdk/client-lambda", () => ({ + InvokeCommand: jest.fn().mockImplementation((input) => ({ input })), + LambdaClient: jest.fn().mockImplementation(() => ({ + send: mockSend, + })), +})); + +describe("ResultStatusLambdaService", () => { + const taskPayload: Task = { + resourceType: "Task", + status: "completed", + intent: "order", + }; + + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("constructs the lambda client with the provided region", () => { + new ResultStatusLambdaService("status-lambda", "eu-west-2"); + + expect(LambdaClient).toHaveBeenCalledWith({ region: "eu-west-2" }); + }); + + it("invokes the configured status lambda with the task payload", async () => { + mockSend.mockResolvedValue({}); + const service = new ResultStatusLambdaService("status-lambda", "eu-west-2"); + + const result = await service.sendTask(taskPayload); + + expect(InvokeCommand).toHaveBeenCalledWith({ + FunctionName: "status-lambda", + Payload: Buffer.from(JSON.stringify(taskPayload)), + }); + expect(mockSend).toHaveBeenCalledWith({ + input: { + FunctionName: "status-lambda", + Payload: Buffer.from(JSON.stringify(taskPayload)), + }, + }); + expect(result).toEqual({ + resourceType: "OperationOutcome", + issue: [ + { + severity: "information", + code: "informational", + diagnostics: "Status update lambda invoked successfully", + }, + ], + }); + }); + + it("returns an error outcome when the invoked lambda reports a function error", async () => { + mockSend.mockResolvedValue({ FunctionError: "Unhandled" }); + const service = new ResultStatusLambdaService("status-lambda", "eu-west-2"); + + const result = await service.sendTask(taskPayload); + + expect(result).toEqual({ + resourceType: "OperationOutcome", + issue: [ + { + severity: "error", + code: "exception", + diagnostics: "Status lambda returned an error: Unhandled", + }, + ], + }); + }); + + it("returns a fatal outcome when invoking the lambda throws", async () => { + mockSend.mockRejectedValue(new Error("network failure")); + const service = new ResultStatusLambdaService("status-lambda", "eu-west-2"); + + const result = await service.sendTask(taskPayload); + + expect(result).toEqual({ + resourceType: "OperationOutcome", + issue: [ + { + severity: "fatal", + code: "exception", + diagnostics: "Failed to invoke status lambda: network failure", + }, + ], + }); + }); +}); diff --git a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts b/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts new file mode 100644 index 000000000..1f51740b4 --- /dev/null +++ b/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts @@ -0,0 +1,69 @@ +// Wraps AWS Lambda invocation in a service class +// Takes the lambda name + region in the constructor +// Has a sendTask() method that: +// JSON‑stringifies the Task +// Invokes the status lambda +// Returns a Promise +// Returns an OperationOutcome (FHIR standard) on success/failure +// Keeps your index.ts clean and simple +import { InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda"; +import { OperationOutcome } from "fhir/r4"; + +export class ResultStatusLambdaService { + private lambdaClient: LambdaClient; + + constructor( + private lambdaName: string, + private region: string, + ) { + this.lambdaClient = new LambdaClient({ region }); + } + + async sendTask(taskPayload: any): Promise { + const payloadString = JSON.stringify(taskPayload); + + const command = new InvokeCommand({ + FunctionName: this.lambdaName, + Payload: Buffer.from(payloadString), + }); + + try { + const response = await this.lambdaClient.send(command); + + if (response.FunctionError) { + return { + resourceType: "OperationOutcome", + issue: [ + { + severity: "error", + code: "exception", + diagnostics: `Status lambda returned an error: ${response.FunctionError}`, + }, + ], + }; + } + + return { + resourceType: "OperationOutcome", + issue: [ + { + severity: "information", + code: "informational", + diagnostics: "Status update lambda invoked successfully", + }, + ], + }; + } catch (error: any) { + return { + resourceType: "OperationOutcome", + issue: [ + { + severity: "fatal", + code: "exception", + diagnostics: `Failed to invoke status lambda: ${error.message}`, + }, + ], + }; + } + } +} diff --git a/lambdas/src/hiv-result-processor-lambda/task-builder.test.ts b/lambdas/src/hiv-result-processor-lambda/task-builder.test.ts new file mode 100644 index 000000000..873f7c23b --- /dev/null +++ b/lambdas/src/hiv-result-processor-lambda/task-builder.test.ts @@ -0,0 +1,104 @@ +import { Observation, Task } from "fhir/r4"; + +import { buildTaskFromObservation } from "./task-builder"; + +describe("buildTaskFromObservation", () => { + const fixedDate = new Date("2026-04-17T10:20:30.000Z"); + const baseObservation: Pick = { + resourceType: "Observation", + status: "final", + code: { + coding: [ + { + system: "http://loinc.org", + code: "75622-1", + }, + ], + }, + }; + + beforeEach(() => { + jest.useFakeTimers().setSystemTime(fixedDate.getTime()); + }); + + afterEach(() => { + jest.useRealTimers(); + }); + + it("builds the expected task from a valid observation", () => { + const observation: Observation = { + ...baseObservation, + basedOn: [{ reference: "ServiceRequest/order-123" }], + subject: { reference: "Patient/patient-456" }, + performer: [{ reference: "Organization/supplier-789" }], + }; + + const result = buildTaskFromObservation(observation) as Task; + + expect(result).toEqual({ + resourceType: "Task", + identifier: [ + { + system: "https://fhir.hometest.nhs.uk/Id/order-id", + value: "order-123", + }, + ], + status: "completed", + intent: "order", + basedOn: [ + { + reference: "ServiceRequest/order-123", + type: "ServiceRequest", + }, + ], + requester: { + reference: "Organization/supplier-789", + }, + for: { + reference: "Patient/patient-456", + }, + businessStatus: { + coding: [ + { + system: "https://fhir.hometest.nhs.uk/CodeSystem/result-business-status", + code: "result-available", + display: "Result available to patient", + }, + ], + text: "result-available", + }, + authoredOn: "2026-04-17T10:20:30.000Z", + lastModified: "2026-04-17T10:20:30.000Z", + }); + }); + + it("throws when basedOn reference is missing", () => { + const observation: Observation = { + ...baseObservation, + subject: { reference: "Patient/patient-456" }, + performer: [{ reference: "Organization/supplier-789" }], + }; + + expect(() => buildTaskFromObservation(observation)).toThrow("Missing basedOn reference"); + }); + + it("throws when subject reference is missing", () => { + const observation: Observation = { + ...baseObservation, + basedOn: [{ reference: "ServiceRequest/order-123" }], + performer: [{ reference: "Organization/supplier-789" }], + }; + + expect(() => buildTaskFromObservation(observation)).toThrow("Missing subject reference"); + }); + + it("throws when performer reference is missing", () => { + const observation: Observation = { + ...baseObservation, + basedOn: [{ reference: "ServiceRequest/order-123" }], + subject: { reference: "Patient/patient-456" }, + }; + + expect(() => buildTaskFromObservation(observation)).toThrow("Missing performer reference"); + }); +}); diff --git a/lambdas/src/hiv-result-processor-lambda/task-builder.ts b/lambdas/src/hiv-result-processor-lambda/task-builder.ts new file mode 100644 index 000000000..8ef51f3db --- /dev/null +++ b/lambdas/src/hiv-result-processor-lambda/task-builder.ts @@ -0,0 +1,72 @@ +// Takes the raw FHIR Observation +// Extracts orderUid, patientId, supplierId +// Builds the FHIR Task payload exactly as HOTE-1100 requires +// Returns the Task object +import { Observation } from "fhir/r4"; + +import { InterpretationCode } from "./models"; +import { extractInterpretationCodeFromFHIRObservation } from "./validation-service"; + +// Helper functions to extract identifiers from Observation +function extractOrderUid(observation: Observation): string { + const reference = observation.basedOn?.[0]?.reference; + if (!reference) throw new Error("Missing basedOn reference"); + return reference.split("/")[1]; +} + +function extractPatientId(observation: Observation): string { + const reference = observation.subject?.reference; + if (!reference) throw new Error("Missing subject reference"); + return reference.split("/")[1]; +} + +function extractSupplierId(observation: Observation): string { + const reference = observation.performer?.[0]?.reference; + if (!reference) throw new Error("Missing performer reference"); + return reference.split("/")[1]; +} + +// Build the FHIR Task payload for the status lambda +export function buildTaskFromObservation(observation: Observation) { + const orderUid = extractOrderUid(observation); + const patientId = extractPatientId(observation); + const supplierId = extractSupplierId(observation); + + const now = new Date().toISOString(); + + return { + resourceType: "Task", + identifier: [ + { + system: "https://fhir.hometest.nhs.uk/Id/order-id", + value: orderUid, + }, + ], + status: "completed", + intent: "order", + basedOn: [ + { + reference: `ServiceRequest/${orderUid}`, + type: "ServiceRequest", + }, + ], + requester: { + reference: `Organization/${supplierId}`, + }, + for: { + reference: `Patient/${patientId}`, + }, + businessStatus: { + coding: [ + { + system: "https://fhir.hometest.nhs.uk/CodeSystem/result-business-status", + code: "result-available", + display: "Result available to patient", + }, + ], + text: "result-available", + }, + authoredOn: now, + lastModified: now, + }; +} diff --git a/lambdas/src/hiv-result-processor-lambda/validation-service.test.ts b/lambdas/src/hiv-result-processor-lambda/validation-service.test.ts new file mode 100644 index 000000000..30f46cfc7 --- /dev/null +++ b/lambdas/src/hiv-result-processor-lambda/validation-service.test.ts @@ -0,0 +1,436 @@ +import { APIGatewayProxyEvent } from "aws-lambda"; +import { Observation } from "fhir/r4"; + +import { ConsoleCommons } from "../lib/commons"; +import { ResultStatus } from "../lib/types/status"; +import * as utils from "../lib/utils/utils"; +import * as validationUtils from "../lib/utils/validation-utils"; +import { InterpretationCode, orderResultFHIRObservationSchema } from "./models"; +import * as validation from "./validation-service"; + +describe("validation-service", () => { + let commons: jest.Mocked; + + beforeEach(() => { + commons = { + logError: jest.fn(), + logInfo: jest.fn(), + logDebug: jest.fn(), + }; + commons.logError.mockReset(); + commons.logInfo.mockReset(); + commons.logDebug.mockReset(); + }); + + afterEach(() => { + jest.restoreAllMocks(); + jest.clearAllMocks(); + }); + + describe("validateBody", () => { + it("throws error when body is null", () => { + expect(() => validation.validateAndExtractObservation(null, commons)).toThrow( + "Body is empty", + ); + }); + + it("throws error when body is empty object", () => { + expect(() => validation.validateAndExtractObservation("{}", commons)).toThrow( + "Body is empty", + ); + }); + + it("throws error when body is invalid JSON", () => { + expect(() => validation.validateAndExtractObservation("{invalid json}", commons)).toThrow(); + }); + + it("throws error when schema validation fails", () => { + const generateReadableErrorSpy = jest + .spyOn(validationUtils, "generateReadableError") + .mockReturnValue("Invalid schema"); + + const invalidObservation = JSON.stringify({ foo: "bar" }); + jest.spyOn(orderResultFHIRObservationSchema, "safeParse").mockReturnValue({ + success: false, + error: { issues: [{ message: "Invalid schema" }] }, + } as ReturnType); + + expect(() => validation.validateAndExtractObservation(invalidObservation, commons)).toThrow(); + expect(generateReadableErrorSpy).toHaveBeenCalledTimes(1); + }); + + it("does not throw when body is valid and schema passes", () => { + const validObservation = JSON.stringify({ resourceType: "Observation" }); + jest.spyOn(orderResultFHIRObservationSchema, "safeParse").mockReturnValue({ + success: true, + data: { resourceType: "Observation" }, + } as ReturnType); + + expect(() => + validation.validateAndExtractObservation(validObservation, commons), + ).not.toThrow(); + }); + }); + + describe("extractAndValidateObservationFields", () => { + const makeEvent = (body: string | null, headers: Record = {}) => + ({ body, headers }) as unknown as APIGatewayProxyEvent; + + const observation = { + basedOn: [{ reference: "ServiceRequest/550e8400-e29b-41d4-a716-446655440000" }], + subject: { reference: "Patient/550e8400-e29b-41d4-a716-446655440001" }, + performer: [{ reference: "Organization/supplier-123" }], + } as Observation; + + it("returns invalid result when validateBody throws", () => { + jest.spyOn(validation, "validateAndExtractObservation").mockImplementation(() => { + throw new Error("bad body"); + }); + + const result = validation.extractAndValidateObservationFields(makeEvent('{"x":1}'), commons); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toEqual({ + errorCode: 400, + errorType: "invalid", + errorMessage: "bad body", + severity: "error", + }); + } + }); + + it("returns invalid result when correlation header is invalid", () => { + jest.spyOn(validation, "validateAndExtractObservation").mockReturnValue(observation); + jest.spyOn(utils, "getCorrelationIdFromEventHeaders").mockImplementation(() => { + throw new Error("missing correlation id"); + }); + + const result = validation.extractAndValidateObservationFields(makeEvent('{"x":1}'), commons); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toEqual({ + errorCode: 400, + errorType: "invalid", + errorMessage: "missing correlation id", + severity: "error", + }); + } + }); + + it("returns invalid result when identifier extraction fails", () => { + jest.spyOn(validation, "validateAndExtractObservation").mockImplementation(() => { + throw new Error("Unable to extract necessary identifiers from Observation"); + }); + jest.spyOn(utils, "getCorrelationIdFromEventHeaders").mockReturnValue("corr-id"); + jest.spyOn(validation, "extractOrderUidFromFHIRObservation").mockImplementation(() => { + throw new Error("bad order uid"); + }); + + const result = validation.extractAndValidateObservationFields(makeEvent('{"x":1}'), commons); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toEqual({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Unable to extract necessary identifiers from Observation", + severity: "error", + }); + } + }); + + it("returns valid result with observation and identifiers on success", () => { + jest.spyOn(validation, "validateAndExtractObservation").mockReturnValue(observation); + jest.spyOn(utils, "getCorrelationIdFromEventHeaders").mockReturnValue("corr-id"); + + const observationEvent = { + basedOn: [{ reference: "ServiceRequest/550e8400-e29b-41d4-a716-446655440000" }], + subject: { reference: "Patient/550e8400-e29b-41d4-a716-446655440001" }, + performer: [{ reference: "Organization/supplier-123" }], + }; + const result = validation.extractAndValidateObservationFields( + makeEvent(JSON.stringify(observationEvent)), + commons, + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.identifiers).toEqual({ + orderUid: "550e8400-e29b-41d4-a716-446655440000", + patientId: "550e8400-e29b-41d4-a716-446655440001", + supplierId: "supplier-123", + correlationId: "corr-id", + }); + expect(result.data.observation).toEqual(observation); + } + }); + }); + + describe("validateDBData", () => { + let identifiers: any; + let observation: any; + let testOrderResult: any; + + beforeEach(() => { + identifiers = { + orderUid: "order-uid", + patientId: "patient-uid", + supplierId: "supplier-123", + correlationId: "corr-id", + }; + + observation = { + interpretation: [{ coding: [{ code: "N" }] }], + }; + + testOrderResult = { + correlation_id: "corr-id", + result_status: ResultStatus.Result_Available, + patient_uid: "patient-uid", + supplier_id: "supplier-123", + }; + + jest + .spyOn(validation, "extractInterpretationCodeFromFHIRObservation") + .mockReturnValue(InterpretationCode.Normal); + }); + + it("returns not-found when testOrderResult is null", async () => { + const result = await validation.validateDBData( + identifiers, + observation, + null as any, + commons, + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toEqual({ + errorCode: 404, + errorType: "not-found", + errorMessage: "No order found for orderUid order-uid", + severity: "error", + }); + } + }); + + it("returns conflict when idempotency check fails (different result)", async () => { + testOrderResult.result_status = ResultStatus.Result_Withheld; // mismatch with mapping + const result = await validation.validateDBData( + identifiers, + observation, + testOrderResult, + commons, + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toEqual({ + errorCode: 409, + errorType: "conflict", + errorMessage: + "A different result has already been submitted for this order with the same correlation ID", + severity: "error", + }); + } + }); + + it("returns success and isIdempotent=true when idempotency check passes (same result)", async () => { + testOrderResult.result_status = ResultStatus.Result_Available; // matches mapping + const result = await validation.validateDBData( + identifiers, + observation, + testOrderResult, + commons, + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ + isIdempotent: true, + }); + } + }); + + it("returns invalid when patient_uid does not match", async () => { + testOrderResult.correlation_id = undefined; // skip idempotency + testOrderResult.patient_uid = "other-patient"; + const result = await validation.validateDBData( + identifiers, + observation, + testOrderResult, + commons, + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toEqual({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Patient ID in Observation does not match order record", + severity: "error", + }); + } + }); + + it("returns forbidden when supplier_id does not match", async () => { + testOrderResult.correlation_id = undefined; // skip idempotency + testOrderResult.supplier_id = "other-supplier"; + const result = await validation.validateDBData( + identifiers, + observation, + testOrderResult, + commons, + ); + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error).toEqual({ + errorCode: 403, + errorType: "forbidden", + errorMessage: "Supplier not authorized for this order", + severity: "error", + }); + } + }); + + it("returns valid when all checks pass and not idempotent", async () => { + testOrderResult.correlation_id = undefined; // skip idempotency + testOrderResult.patient_uid = "patient-uid"; + testOrderResult.supplier_id = "supplier-123"; + const result = await validation.validateDBData( + identifiers, + observation, + testOrderResult, + commons, + ); + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toEqual({ isIdempotent: false }); + } + }); + }); + + describe("extractOrderUidFromFHIRObservation", () => { + beforeEach(() => { + jest.spyOn(utils, "isUUID").mockImplementation((id: string) => { + // Accepts only a specific UUID for test + return id === "550e8400-e29b-41d4-a716-446655440000"; + }); + }); + + it("extracts order UID from valid basedOn reference", () => { + const observation = { + basedOn: [{ reference: "ServiceRequest/550e8400-e29b-41d4-a716-446655440000" }], + } as any; + const result = validation.extractOrderUidFromFHIRObservation(observation); + expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); + }); + + it("throws if basedOn is empty array", () => { + const observation = { basedOn: [] } as any; + expect(() => validation.extractOrderUidFromFHIRObservation(observation)).toThrow( + "Observation.basedOn is empty", + ); + }); + + it("throws if basedOn[0].reference is missing", () => { + const observation = { basedOn: [{}] } as any; + expect(() => validation.extractOrderUidFromFHIRObservation(observation)).toThrow( + "Observation.basedOn[0].reference is missing", + ); + }); + + it("throws if basedOn reference format is invalid", () => { + const observation = { basedOn: [{ reference: "InvalidReferenceFormat" }] } as any; + expect(() => validation.extractOrderUidFromFHIRObservation(observation)).toThrow( + "Invalid basedOn reference format", + ); + }); + + it("throws if orderUID is not a valid UUID", () => { + jest.spyOn(utils, "isUUID").mockReturnValue(false); + const observation = { basedOn: [{ reference: "ServiceRequest/not-a-uuid" }] } as any; + expect(() => validation.extractOrderUidFromFHIRObservation(observation)).toThrow( + "Invalid orderUID format", + ); + }); + }); + + describe("extractPatientIdFromFHIRObservation", () => { + beforeEach(() => { + jest.spyOn(utils, "isUUID").mockImplementation((id: string) => { + // Accepts only a specific UUID for test + return id === "550e8400-e29b-41d4-a716-446655440001"; + }); + }); + + it("extracts patient ID from valid subject reference", () => { + const observation = { + subject: { reference: "Patient/550e8400-e29b-41d4-a716-446655440001" }, + } as any; + const result = validation.extractPatientIdFromFHIRObservation(observation); + expect(result).toBe("550e8400-e29b-41d4-a716-446655440001"); + }); + + it("throws if subject reference format is invalid", () => { + const observation = { + subject: { reference: "InvalidReferenceFormat" }, + } as any; + expect(() => validation.extractPatientIdFromFHIRObservation(observation)).toThrow( + "Invalid subject reference format", + ); + }); + + it("throws if patient ID is not a valid UUID", () => { + jest.spyOn(utils, "isUUID").mockReturnValue(false); + const observation = { + subject: { reference: "Patient/not-a-uuid" }, + } as any; + expect(() => validation.extractPatientIdFromFHIRObservation(observation)).toThrow( + "Invalid patient ID format", + ); + }); + }); + + describe("extractSupplierIdFromFHIRObservation", () => { + it("extracts supplier ID from valid performer reference", () => { + const observation = { + performer: [{ reference: "Organization/supplier-123" }], + } as any; + const result = validation.extractSupplierIdFromFHIRObservation(observation); + expect(result).toBe("supplier-123"); + }); + + it("throws if performer reference format is invalid", () => { + const observation = { + performer: [{ reference: "InvalidReferenceFormat" }], + } as any; + expect(() => validation.extractSupplierIdFromFHIRObservation(observation)).toThrow( + "Invalid performer reference format", + ); + }); + + it("throws if performer array is missing", () => { + const observation = {} as any; + expect(() => validation.extractSupplierIdFromFHIRObservation(observation)).toThrow(); + }); + + it("throws if performer[0] is missing", () => { + const observation = { performer: [] } as any; + expect(() => validation.extractSupplierIdFromFHIRObservation(observation)).toThrow(); + }); + + it("throws if performer[0].reference is missing", () => { + const observation = { performer: [{}] } as any; + expect(() => validation.extractSupplierIdFromFHIRObservation(observation)).toThrow(); + }); + }); + + describe("extractInterpretationCodeFromFHIRObservation", () => { + it("extracts interpretation code from valid observation", () => { + const observation = { + interpretation: [{ coding: [{ code: "POS" }] }], + } as any; + const result = validation.extractInterpretationCodeFromFHIRObservation(observation); + expect(result).toBe("POS"); + }); + }); +}); diff --git a/lambdas/src/hiv-result-processor-lambda/validation-service.ts b/lambdas/src/hiv-result-processor-lambda/validation-service.ts new file mode 100644 index 000000000..a783cc56f --- /dev/null +++ b/lambdas/src/hiv-result-processor-lambda/validation-service.ts @@ -0,0 +1,228 @@ +import { APIGatewayProxyEvent } from "aws-lambda"; +import { Observation } from "fhir/r4"; + +import { ConsoleCommons } from "../lib/commons"; +import { OrderResultSummary } from "../lib/db/order-db"; +import { getCorrelationIdFromEventHeaders, isUUID } from "../lib/utils/utils"; +import { generateReadableError } from "../lib/utils/validation-utils"; +import { + Identifiers, + InterpretationCode, + orderResultFHIRObservationSchema, + resultCodeMapping, +} from "./models"; +import { ValidationResult, ValidationResultError, errorResult, successResult } from "./validation"; + +function invalidErrorResult(errorMessage: string): ValidationResultError { + return errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: errorMessage, + severity: "error", + }); +} + +export const validateAndExtractObservation = ( + body: string | null, + commons: ConsoleCommons, +): Observation => { + let observation: Observation; + + try { + if (body === "{}" || body === null) { + throw new Error("Body is empty"); + } + observation = JSON.parse(body); + } catch (error) { + commons.logError("order-result-lambda", "Invalid JSON in request body", { error }); + throw error; + } + + const validationResult = orderResultFHIRObservationSchema.safeParse(observation); + + if (!validationResult.success) { + const errorDetails = generateReadableError(validationResult.error); + + commons.logError("order-result-lambda", "Validation failed", { error: errorDetails }); + throw new Error(`FHIR Observation validation error: ${errorDetails}`); + } + + return observation; +}; + +export function extractAndValidateObservationFields( + event: APIGatewayProxyEvent, + commons: ConsoleCommons, +): ValidationResult<{ observation: Observation; identifiers: Identifiers }> { + let observation: Observation; + try { + observation = validateAndExtractObservation(event.body, commons); + } catch (error) { + return invalidErrorResult((error as Error).message); + } + + let correlationId: string; + + try { + correlationId = getCorrelationIdFromEventHeaders(event); + } catch (error) { + commons.logError("order-result-lambda", "Header validation failed", { + error: (error as Error).message, + }); + return invalidErrorResult((error as Error).message); + } + + let orderUid: string, patientId: string, supplierId: string; + + try { + orderUid = extractOrderUidFromFHIRObservation(observation); + patientId = extractPatientIdFromFHIRObservation(observation); + supplierId = extractSupplierIdFromFHIRObservation(observation); + } catch (error) { + commons.logError("order-result-lambda", "Error extracting identifiers from Observation", { + error, + }); + + return invalidErrorResult("Unable to extract necessary identifiers from Observation"); + } + + const identifiers: Identifiers = { + orderUid, + patientId, + supplierId, + correlationId, + }; + + return successResult({ + observation, + identifiers, + }); +} + +export async function validateDBData( + identifiers: Identifiers, + observation: Observation, + testOrderResult: OrderResultSummary, + commons: ConsoleCommons, +): Promise> { + const interpretationCode = extractInterpretationCodeFromFHIRObservation(observation); + const { orderUid, patientId, supplierId, correlationId } = identifiers; + + if (!testOrderResult) { + commons.logError("order-result-lambda", "Test order not found for orderUid", { orderUid }); + return errorResult({ + errorCode: 404, + errorType: "not-found", + errorMessage: `No order found for orderUid ${orderUid}`, + severity: "error", + }); + } + + // Idempotency check + if (testOrderResult.correlation_id && testOrderResult.correlation_id === correlationId) { + if (resultCodeMapping[interpretationCode] !== testOrderResult.result_status) { + commons.logError( + "order-result-lambda", + "Idempotency check failed, different result detected on same correlation ID.", + { orderUid, correlationId }, + ); + return errorResult({ + errorCode: 409, + errorType: "conflict", + errorMessage: + "A different result has already been submitted for this order with the same correlation ID", + severity: "error", + }); + } + + commons.logInfo( + "order-result-lambda", + "Duplicate submission with same correlation ID detected, returning success without reprocessing", + { orderUid, correlationId }, + ); + return successResult({ + isIdempotent: true, + }); + } + + if (testOrderResult.patient_uid !== patientId) { + commons.logError( + "order-result-lambda", + "Patient ID in Observation does not match test order record", + { orderUid, patientId }, + ); + return invalidErrorResult("Patient ID in Observation does not match order record"); + } + + if (testOrderResult.supplier_id !== supplierId) { + commons.logError( + "order-result-lambda", + "Supplier ID in Observation does not match test order record", + { orderUid, supplierId }, + ); + return errorResult({ + errorCode: 403, + errorType: "forbidden", + errorMessage: "Supplier not authorized for this order", + severity: "error", + }); + } + + return successResult({ isIdempotent: false }); +} + +export function extractOrderUidFromFHIRObservation(observation: Observation): string { + if (observation.basedOn?.length === 0) { + throw new Error("Observation.basedOn is empty"); + } + + const reference = observation.basedOn?.[0]?.reference; + + if (!reference) { + throw new Error("Observation.basedOn[0].reference is missing"); + } + + // Extract UUID from reference like "ServiceRequest/550e8400-e29b-41d4-a716-446655440000" + const parts = reference.split("/"); + if (parts.length !== 2) { + throw new Error("Invalid basedOn reference format"); + } + + const orderUID = parts[1]; + + if (!isUUID(orderUID)) { + throw new Error("Invalid orderUID format"); + } + + return orderUID; +} + +export function extractPatientIdFromFHIRObservation(observation: Observation): string { + const parts = observation.subject!.reference!.split("/"); + if (parts.length !== 2) { + throw new Error("Invalid subject reference format"); + } + + const patientId = parts[1]; + + if (!isUUID(patientId)) { + throw new Error("Invalid patient ID format"); + } + + return patientId; +} + +export function extractSupplierIdFromFHIRObservation(observation: Observation): string { + const parts = observation.performer![0].reference!.split("/"); + if (parts.length !== 2) { + throw new Error("Invalid performer reference format"); + } + + return parts[1]; +} + +export function extractInterpretationCodeFromFHIRObservation( + observation: Observation, +): InterpretationCode { + return observation.interpretation![0].coding![0].code as InterpretationCode; +} diff --git a/lambdas/src/hiv-result-processor-lambda/validation.ts b/lambdas/src/hiv-result-processor-lambda/validation.ts new file mode 100644 index 000000000..de78b9c18 --- /dev/null +++ b/lambdas/src/hiv-result-processor-lambda/validation.ts @@ -0,0 +1,35 @@ +import { ErrorStatusCode } from "../lib/fhir-response"; + +export interface ValidationError { + errorCode: ErrorStatusCode; + errorType: "not-found" | "invalid" | "forbidden" | "conflict"; + errorMessage: string; + severity: "error" | "warning" | "information"; +} +export type ValidationResult = ValidationResultSuccess | ValidationResultError; +export type ValidationResultSuccess = { + success: true; + data: T; + error?: never; +}; +export type ValidationResultError = { + success: false; + data?: never; + error: ValidationError; +}; + +export function successResult(): ValidationResult; +export function successResult(data: T): ValidationResult; +export function successResult(data?: T): ValidationResult { + return { + success: true, + data: data as T, + }; +} + +export function errorResult(error: ValidationError): ValidationResultError { + return { + success: false, + error, + }; +} diff --git a/local-environment/infra/main.tf b/local-environment/infra/main.tf index 22ca67ebe..a161223d5 100644 --- a/local-environment/infra/main.tf +++ b/local-environment/infra/main.tf @@ -504,6 +504,32 @@ module "get_results_lambda" { } } +module "hiv_results_lambda" { + source = "./modules/lambda" + + project_name = var.project_name + function_name = "hiv-results-processor" + zip_path = "${path.module}/../../lambdas/dist/hiv-result-processor-lambda.zip" + lambda_role_arn = aws_iam_role.lambda_role.arn + environment = var.environment + api_gateway_id = aws_api_gateway_rest_api.api.id + api_gateway_root_resource_id = aws_api_gateway_rest_api.api.root_resource_id + api_gateway_execution_arn = aws_api_gateway_rest_api.api.execution_arn + api_path = "hiv-results" + http_method = "POST" + lambda_role_policy_attachment = aws_iam_role_policy_attachment.lambda_basic + + enable_cors = true + cors_allow_origin = "http://localhost:3000" + cors_allow_methods = ["POST", "OPTIONS"] + cors_allow_headers = ["Content-Type", "Authorization", "X-Requested-With"] + + environment_variables = { + RESULT_STATUS_LAMBDA_NAME = "result-status-lambda" + AWS_REGION = "eu-west-2" + } +} + module "order_status_lambda" { source = "./modules/lambda" From 4f7a19f23de428467103a595b928b8609694b84f Mon Sep 17 00:00:00 2001 From: lauren5656 Date: Mon, 20 Apr 2026 10:40:00 +0100 Subject: [PATCH 02/30] update main --- lambdas/src/hiv-result-processor-lambda/task-builder.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lambdas/src/hiv-result-processor-lambda/task-builder.ts b/lambdas/src/hiv-result-processor-lambda/task-builder.ts index 8ef51f3db..f13de5d46 100644 --- a/lambdas/src/hiv-result-processor-lambda/task-builder.ts +++ b/lambdas/src/hiv-result-processor-lambda/task-builder.ts @@ -1,7 +1,7 @@ // Takes the raw FHIR Observation // Extracts orderUid, patientId, supplierId // Builds the FHIR Task payload exactly as HOTE-1100 requires -// Returns the Task object +// Returns the Task objects import { Observation } from "fhir/r4"; import { InterpretationCode } from "./models"; From 9c7549d2d555fc52d5a3634f844a5c7700851a00 Mon Sep 17 00:00:00 2001 From: lauren5656 Date: Mon, 20 Apr 2026 12:02:11 +0100 Subject: [PATCH 03/30] run precommit --- local-environment/infra/main.tf | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/local-environment/infra/main.tf b/local-environment/infra/main.tf index a161223d5..a9b0a9ab3 100644 --- a/local-environment/infra/main.tf +++ b/local-environment/infra/main.tf @@ -506,7 +506,7 @@ module "get_results_lambda" { module "hiv_results_lambda" { source = "./modules/lambda" - + project_name = var.project_name function_name = "hiv-results-processor" zip_path = "${path.module}/../../lambdas/dist/hiv-result-processor-lambda.zip" @@ -518,12 +518,12 @@ module "hiv_results_lambda" { api_path = "hiv-results" http_method = "POST" lambda_role_policy_attachment = aws_iam_role_policy_attachment.lambda_basic - + enable_cors = true cors_allow_origin = "http://localhost:3000" cors_allow_methods = ["POST", "OPTIONS"] cors_allow_headers = ["Content-Type", "Authorization", "X-Requested-With"] - + environment_variables = { RESULT_STATUS_LAMBDA_NAME = "result-status-lambda" AWS_REGION = "eu-west-2" From 056793bdddeb41d63a8508becc966e90dde25325 Mon Sep 17 00:00:00 2001 From: lewisbirks Date: Mon, 20 Apr 2026 17:22:02 +0100 Subject: [PATCH 04/30] refactor: extract out a lambda http client Signed-off-by: lewisbirks --- .../hiv-result-processor-lambda/index.test.ts | 10 +- .../src/hiv-result-processor-lambda/index.ts | 4 +- .../hiv-result-processor-lambda/init.test.ts | 38 +++- .../src/hiv-result-processor-lambda/init.ts | 12 +- .../result-status-lambda-service.test.ts | 86 ++++--- .../result-status-lambda-service.ts | 59 ++--- .../task-builder.ts | 3 + lambdas/src/lib/http/http-client.ts | 3 +- .../src/lib/http/lambda-http-client.test.ts | 215 ++++++++++++++++++ lambdas/src/lib/http/lambda-http-client.ts | 160 +++++++++++++ 10 files changed, 490 insertions(+), 100 deletions(-) create mode 100644 lambdas/src/lib/http/lambda-http-client.test.ts create mode 100644 lambdas/src/lib/http/lambda-http-client.ts diff --git a/lambdas/src/hiv-result-processor-lambda/index.test.ts b/lambdas/src/hiv-result-processor-lambda/index.test.ts index 656218994..02609b9eb 100644 --- a/lambdas/src/hiv-result-processor-lambda/index.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/index.test.ts @@ -1,6 +1,6 @@ import { APIGatewayProxyEvent } from "aws-lambda"; -import { createFhirErrorResponse, createFhirResponse } from "../lib/fhir-response"; +import { createFhirErrorResponse } from "../lib/fhir-response"; import { handler } from "./index"; import { InterpretationCode } from "./models"; @@ -12,7 +12,7 @@ jest.mock("./init", () => { logError: jest.fn(), }, resultStatusLambdaService: { - sendTask: jest.fn(), + sendResult: jest.fn(), }, }; return { @@ -86,7 +86,7 @@ describe("hiv-results-processor handler", () => { const res = await handler(event); expect(res.statusCode).toBe(200); - expect(initMock.resultStatusLambdaService.sendTask).not.toHaveBeenCalled(); + expect(initMock.resultStatusLambdaService.sendResult).not.toHaveBeenCalled(); }); it("builds task and calls status lambda for negative results", async () => { @@ -95,13 +95,13 @@ describe("hiv-results-processor handler", () => { const res = await handler(event); expect(buildTaskFromObservation).toHaveBeenCalledWith(observation); - expect(initMock.resultStatusLambdaService.sendTask).toHaveBeenCalledWith({ mockTask: true }); + expect(initMock.resultStatusLambdaService.sendResult).toHaveBeenCalledWith({ mockTask: true }); expect(res.statusCode).toBe(200); }); it("returns 500 if status lambda invocation fails", async () => { extractInterpretationCodeFromFHIRObservation.mockReturnValue(InterpretationCode.Normal); - initMock.resultStatusLambdaService.sendTask.mockRejectedValueOnce(new Error("fail")); + initMock.resultStatusLambdaService.sendResult.mockRejectedValueOnce(new Error("fail")); const res = await handler(event); diff --git a/lambdas/src/hiv-result-processor-lambda/index.ts b/lambdas/src/hiv-result-processor-lambda/index.ts index 04b067ae4..5c9b9428a 100644 --- a/lambdas/src/hiv-result-processor-lambda/index.ts +++ b/lambdas/src/hiv-result-processor-lambda/index.ts @@ -36,8 +36,10 @@ export const handler = async (event: APIGatewayProxyEvent): Promise { @@ -21,34 +26,51 @@ describe("hiv-results-processor init", () => { }); it("initializes commons and resultStatusLambdaService", () => { - const env = init(); + const env = buildEnvironment(); expect(env.commons).toBeInstanceOf(ConsoleCommons); expect(env.resultStatusLambdaService).toBeInstanceOf(ResultStatusLambdaService); }); - it("passes AWS_REGION and RESULT_STATUS_LAMBDA_NAME to ResultStatusLambdaService", () => { - init(); + it("constructs LambdaClient with AWS_REGION", () => { + buildEnvironment(); + + expect(LambdaClient).toHaveBeenCalledWith({ region: "eu-west-2" }); + }); + + it("constructs LambdaHttpClient with the LambdaClient instance and RESULT_STATUS_LAMBDA_NAME", () => { + buildEnvironment(); - expect(ResultStatusLambdaService).toHaveBeenCalledWith("status-lambda", "eu-west-2"); + const lambdaClientInstance = (LambdaClient as jest.Mock).mock.instances[0]; + expect(LambdaHttpClient).toHaveBeenCalledWith(lambdaClientInstance, "status-lambda"); + }); + + it("passes LambdaHttpClient instance to ResultStatusLambdaService", () => { + buildEnvironment(); + + const lambdaHttpClientInstance = (LambdaHttpClient as jest.Mock).mock.instances[0]; + expect(ResultStatusLambdaService).toHaveBeenCalledWith(lambdaHttpClientInstance); }); it("throws if AWS_REGION is missing", () => { delete process.env.AWS_REGION; - expect(() => init()).toThrow("Missing value for an environment variable AWS_REGION"); + expect(() => buildEnvironment()).toThrow( + "Missing value for an environment variable AWS_REGION", + ); }); it("throws if RESULT_STATUS_LAMBDA_NAME is missing", () => { delete process.env.RESULT_STATUS_LAMBDA_NAME; - expect(() => init()).toThrow( + expect(() => buildEnvironment()).toThrow( "Missing value for an environment variable RESULT_STATUS_LAMBDA_NAME", ); }); it("returns the same instance on repeated calls (singleton)", () => { jest.isolateModules(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports const { init: isolatedInit } = require("./init"); const env1 = isolatedInit(); @@ -63,7 +85,6 @@ describe("hiv-results-processor init", () => { jest.isolateModules(() => { jest.clearAllMocks(); - // First call: throw (ResultStatusLambdaService as jest.Mock).mockImplementationOnce(() => { throw new Error("Boom"); }); @@ -72,7 +93,6 @@ describe("hiv-results-processor init", () => { expect(() => isolatedInit()).toThrow("Boom"); - // Second call: should succeed const env = isolatedInit(); expect(env).toBeTruthy(); }); diff --git a/lambdas/src/hiv-result-processor-lambda/init.ts b/lambdas/src/hiv-result-processor-lambda/init.ts index 57437acb1..f25963379 100644 --- a/lambdas/src/hiv-result-processor-lambda/init.ts +++ b/lambdas/src/hiv-result-processor-lambda/init.ts @@ -1,4 +1,8 @@ +import { LambdaClient } from "@aws-sdk/client-lambda"; + +import { getAwsClientOptions } from "../lib/aws/aws-client-config"; import { Commons, ConsoleCommons } from "../lib/commons"; +import { LambdaHttpClient } from "../lib/http/lambda-http-client"; import { retrieveMandatoryEnvVariable } from "../lib/utils/utils"; import { ResultStatusLambdaService } from "./result-status-lambda-service"; @@ -12,11 +16,9 @@ export function buildEnvironment(): Environment { const awsRegion = retrieveMandatoryEnvVariable("AWS_REGION"); const resultStatusLambdaName = retrieveMandatoryEnvVariable("RESULT_STATUS_LAMBDA_NAME"); - - const resultStatusLambdaService = new ResultStatusLambdaService( - resultStatusLambdaName, - awsRegion, - ); + const lambdaClient = new LambdaClient(getAwsClientOptions(awsRegion)); + const lambdaHttpClient = new LambdaHttpClient(lambdaClient, resultStatusLambdaName); + const resultStatusLambdaService = new ResultStatusLambdaService(lambdaHttpClient); return { commons, diff --git a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts b/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts index af67f27f3..50e5aebd9 100644 --- a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts @@ -1,16 +1,14 @@ -import { InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda"; import { Task } from "fhir/r4"; +import { HttpClient, HttpError } from "../lib/http/http-client"; import { ResultStatusLambdaService } from "./result-status-lambda-service"; -const mockSend = jest.fn(); - -jest.mock("@aws-sdk/client-lambda", () => ({ - InvokeCommand: jest.fn().mockImplementation((input) => ({ input })), - LambdaClient: jest.fn().mockImplementation(() => ({ - send: mockSend, - })), -})); +const mockPost = jest.fn(); +const mockHttpClient: HttpClient = { + get: jest.fn(), + post: mockPost, + postRaw: jest.fn(), +}; describe("ResultStatusLambdaService", () => { const taskPayload: Task = { @@ -19,32 +17,46 @@ describe("ResultStatusLambdaService", () => { intent: "order", }; + const correlationId = "test-correlation-id"; + beforeEach(() => { jest.clearAllMocks(); }); - it("constructs the lambda client with the provided region", () => { - new ResultStatusLambdaService("status-lambda", "eu-west-2"); + it("posts the task payload to the correct path with the correlation ID header", async () => { + mockPost.mockResolvedValueOnce({}); + const service = new ResultStatusLambdaService(mockHttpClient); + + await service.sendResult(taskPayload, correlationId); - expect(LambdaClient).toHaveBeenCalledWith({ region: "eu-west-2" }); + expect(mockPost).toHaveBeenCalledWith( + "result/status", + taskPayload, + { "X-Correlation-Id": correlationId }, + "application/fhir+json", + ); }); - it("invokes the configured status lambda with the task payload", async () => { - mockSend.mockResolvedValue({}); - const service = new ResultStatusLambdaService("status-lambda", "eu-west-2"); + it("uses 'null' as correlation ID when none is provided", async () => { + mockPost.mockResolvedValueOnce({}); + const service = new ResultStatusLambdaService(mockHttpClient); - const result = await service.sendTask(taskPayload); + await service.sendResult(taskPayload); + + expect(mockPost).toHaveBeenCalledWith( + "result/status", + taskPayload, + { "X-Correlation-Id": "null" }, + "application/fhir+json", + ); + }); + + it("returns a success OperationOutcome when the post succeeds", async () => { + mockPost.mockResolvedValueOnce({}); + const service = new ResultStatusLambdaService(mockHttpClient); + + const result = await service.sendResult(taskPayload, correlationId); - expect(InvokeCommand).toHaveBeenCalledWith({ - FunctionName: "status-lambda", - Payload: Buffer.from(JSON.stringify(taskPayload)), - }); - expect(mockSend).toHaveBeenCalledWith({ - input: { - FunctionName: "status-lambda", - Payload: Buffer.from(JSON.stringify(taskPayload)), - }, - }); expect(result).toEqual({ resourceType: "OperationOutcome", issue: [ @@ -57,29 +69,29 @@ describe("ResultStatusLambdaService", () => { }); }); - it("returns an error outcome when the invoked lambda reports a function error", async () => { - mockSend.mockResolvedValue({ FunctionError: "Unhandled" }); - const service = new ResultStatusLambdaService("status-lambda", "eu-west-2"); + it("returns an OperationOutcome when the post throws an HttpError", async () => { + mockPost.mockRejectedValueOnce(new HttpError("Not found", 404, "not found")); + const service = new ResultStatusLambdaService(mockHttpClient); - const result = await service.sendTask(taskPayload); + const result = await service.sendResult(taskPayload, correlationId); expect(result).toEqual({ resourceType: "OperationOutcome", issue: [ { - severity: "error", + severity: "fatal", code: "exception", - diagnostics: "Status lambda returned an error: Unhandled", + diagnostics: "Failed to invoke status lambda: Not found", }, ], }); }); - it("returns a fatal outcome when invoking the lambda throws", async () => { - mockSend.mockRejectedValue(new Error("network failure")); - const service = new ResultStatusLambdaService("status-lambda", "eu-west-2"); + it("returns an OperationOutcome when the post throws an unexpected error", async () => { + mockPost.mockRejectedValueOnce(new Error("network failure")); + const service = new ResultStatusLambdaService(mockHttpClient); - const result = await service.sendTask(taskPayload); + const result = await service.sendResult(taskPayload, correlationId); expect(result).toEqual({ resourceType: "OperationOutcome", @@ -87,7 +99,7 @@ describe("ResultStatusLambdaService", () => { { severity: "fatal", code: "exception", - diagnostics: "Failed to invoke status lambda: network failure", + diagnostics: `Failed to invoke status lambda: network failure`, }, ], }); diff --git a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts b/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts index 1f51740b4..c2725d599 100644 --- a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts +++ b/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts @@ -1,48 +1,23 @@ -// Wraps AWS Lambda invocation in a service class -// Takes the lambda name + region in the constructor -// Has a sendTask() method that: -// JSON‑stringifies the Task -// Invokes the status lambda -// Returns a Promise -// Returns an OperationOutcome (FHIR standard) on success/failure -// Keeps your index.ts clean and simple -import { InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda"; import { OperationOutcome } from "fhir/r4"; +import { HttpClient } from "src/lib/http/http-client"; export class ResultStatusLambdaService { - private lambdaClient: LambdaClient; - - constructor( - private lambdaName: string, - private region: string, - ) { - this.lambdaClient = new LambdaClient({ region }); - } - - async sendTask(taskPayload: any): Promise { - const payloadString = JSON.stringify(taskPayload); - - const command = new InvokeCommand({ - FunctionName: this.lambdaName, - Payload: Buffer.from(payloadString), - }); + constructor(private readonly client: HttpClient) {} + // TODO: what is the result? the type should be known + async sendResult(result: unknown, correlationId?: string): Promise { + // check what the return type is, and convert to OperationOutcome if needed, + // this will be the type returned in HOTE-1100 try { - const response = await this.lambdaClient.send(command); - - if (response.FunctionError) { - return { - resourceType: "OperationOutcome", - issue: [ - { - severity: "error", - code: "exception", - diagnostics: `Status lambda returned an error: ${response.FunctionError}`, - }, - ], - }; - } - + await this.client.post( + "result/status", + result, + { "X-Correlation-Id": correlationId ?? "null" }, + "application/fhir+json", + ); + // TODO: Do we need to return anything here? Is it the responsibility of this code to generate + // the operation outcome or should that be higher up. + // Or maybe we don't catch the error here and let that propagate up? return { resourceType: "OperationOutcome", issue: [ @@ -53,14 +28,14 @@ export class ResultStatusLambdaService { }, ], }; - } catch (error: any) { + } catch (error: unknown) { return { resourceType: "OperationOutcome", issue: [ { severity: "fatal", code: "exception", - diagnostics: `Failed to invoke status lambda: ${error.message}`, + diagnostics: `Failed to invoke status lambda: ${(error as Error).message}`, }, ], }; diff --git a/lambdas/src/hiv-result-processor-lambda/task-builder.ts b/lambdas/src/hiv-result-processor-lambda/task-builder.ts index f13de5d46..4e33f7692 100644 --- a/lambdas/src/hiv-result-processor-lambda/task-builder.ts +++ b/lambdas/src/hiv-result-processor-lambda/task-builder.ts @@ -8,6 +8,7 @@ import { InterpretationCode } from "./models"; import { extractInterpretationCodeFromFHIRObservation } from "./validation-service"; // Helper functions to extract identifiers from Observation +// TODO do helper functions like this already exist? function extractOrderUid(observation: Observation): string { const reference = observation.basedOn?.[0]?.reference; if (!reference) throw new Error("Missing basedOn reference"); @@ -27,6 +28,8 @@ function extractSupplierId(observation: Observation): string { } // Build the FHIR Task payload for the status lambda +// TODO what type is the result? +// This will help inform what should be passed to the result-status-lambda-service export function buildTaskFromObservation(observation: Observation) { const orderUid = extractOrderUid(observation); const patientId = extractPatientId(observation); diff --git a/lambdas/src/lib/http/http-client.ts b/lambdas/src/lib/http/http-client.ts index 319803738..cc0eee644 100644 --- a/lambdas/src/lib/http/http-client.ts +++ b/lambdas/src/lib/http/http-client.ts @@ -19,8 +19,9 @@ export class HttpError extends Error { message: string, public readonly status: number, public readonly body?: string, + _cause?: unknown, ) { - super(message); + super(message, { cause: _cause }); this.name = "HttpError"; } } diff --git a/lambdas/src/lib/http/lambda-http-client.test.ts b/lambdas/src/lib/http/lambda-http-client.test.ts new file mode 100644 index 000000000..d67f73d01 --- /dev/null +++ b/lambdas/src/lib/http/lambda-http-client.test.ts @@ -0,0 +1,215 @@ +import { LambdaClient } from "@aws-sdk/client-lambda"; +import { APIGatewayProxyResult } from "aws-lambda"; + +import { HttpError } from "./http-client"; +import { LambdaHttpClient } from "./lambda-http-client"; + +const mockSend = jest.fn(); +const mockLambdaClient = { send: mockSend } as unknown as LambdaClient; + +const encodeResult = (result: APIGatewayProxyResult): Uint8Array => + Buffer.from(JSON.stringify(result)); + +describe("LambdaHttpClient", () => { + let client: LambdaHttpClient; + + beforeEach(() => { + client = new LambdaHttpClient(mockLambdaClient, "my-lambda"); + jest.clearAllMocks(); + }); + + describe("get", () => { + it("returns parsed JSON body on a 2xx response", async () => { + mockSend.mockResolvedValueOnce({ + Payload: encodeResult({ statusCode: 200, headers: {}, body: JSON.stringify({ ok: true }) }), + }); + + const result = await client.get<{ ok: boolean }>("/some-path"); + + expect(result).toEqual({ ok: true }); + }); + + it("sends a GET event with the correct shape", async () => { + mockSend.mockResolvedValueOnce({ + Payload: encodeResult({ + statusCode: 200, + headers: {}, + body: JSON.stringify({ + some: "thing", + }), + }), + }); + + const response = await client.get("/some-path", { "X-Correlation-ID": "abc-123" }); + + expect(response).toEqual({ some: "thing" }); + + expect(mockSend).toHaveBeenCalledWith( + expect.objectContaining({ + input: expect.objectContaining({ + FunctionName: "my-lambda", + InvocationType: "RequestResponse", + Payload: expect.stringContaining('"httpMethod":"GET"'), + }), + }), + ); + + const sentPayload = JSON.parse(mockSend.mock.calls[0][0].input.Payload); + expect(sentPayload).toMatchObject({ + httpMethod: "GET", + path: "/some-path", + headers: expect.objectContaining({ + Accept: "application/json", + "X-Correlation-ID": "abc-123", + }), + body: null, + }); + }); + + it("throws HttpError with status when response is non-2xx", async () => { + mockSend.mockResolvedValueOnce({ + Payload: encodeResult({ statusCode: 404, headers: {}, body: "Not found" }), + }); + + await expect(client.get("/some-path")).rejects.toEqual( + expect.objectContaining>({ + name: "HttpError", + status: 404, + }), + ); + }); + + it("throws HttpError 500 when FunctionError is set", async () => { + mockSend.mockResolvedValueOnce({ + FunctionError: "Unhandled", + Payload: encodeResult({ statusCode: 500, headers: {}, body: "" }), + }); + + await expect(client.get("/some-path")).rejects.toEqual( + expect.objectContaining>({ + name: "HttpError", + status: 500, + }), + ); + }); + + it("throws HttpError 500 when Payload is undefined", async () => { + mockSend.mockResolvedValueOnce({ Payload: undefined }); + + await expect(client.get("/some-path")).rejects.toEqual( + expect.objectContaining>({ + name: "HttpError", + status: 500, + }), + ); + }); + }); + + describe("post", () => { + it("returns parsed JSON body on a 2xx response", async () => { + mockSend.mockResolvedValueOnce({ + Payload: encodeResult({ + statusCode: 201, + headers: {}, + body: JSON.stringify({ id: "123" }), + }), + }); + + const result = await client.post<{ id: string }>("/some-path", { foo: "bar" }); + + expect(result).toEqual({ id: "123" }); + }); + + it("sends a POST event with the correct shape and serialised body", async () => { + mockSend.mockResolvedValueOnce({ + Payload: encodeResult({ statusCode: 200, headers: {}, body: JSON.stringify({}) }), + }); + + await client.post("/some-path", { foo: "bar" }, { "X-Correlation-ID": "abc-123" }); + + const sentPayload = JSON.parse(mockSend.mock.calls[0][0].input.Payload); + expect(sentPayload).toMatchObject({ + httpMethod: "POST", + path: "/some-path", + headers: expect.objectContaining({ + Accept: "application/json", + "Content-Type": "application/json", + "X-Correlation-ID": "abc-123", + }), + body: JSON.stringify({ foo: "bar" }), + }); + }); + + it("uses provided contentType header", async () => { + mockSend.mockResolvedValueOnce({ + Payload: encodeResult({ statusCode: 200, headers: {}, body: JSON.stringify({}) }), + }); + + await client.post("/some-path", "raw=value", {}, "application/x-www-form-urlencoded"); + + const sentPayload = JSON.parse(mockSend.mock.calls[0][0].input.Payload); + expect(sentPayload.headers["Content-Type"]).toBe("application/x-www-form-urlencoded"); + }); + + it("throws HttpError with status when response is non-2xx", async () => { + mockSend.mockResolvedValueOnce({ + Payload: encodeResult({ statusCode: 400, headers: {}, body: "bad request" }), + }); + + await expect(client.post("/some-path", {})).rejects.toEqual( + expect.objectContaining>({ + name: "HttpError", + status: 400, + }), + ); + }); + + it("throws HttpError 500 when FunctionError is set", async () => { + mockSend.mockResolvedValueOnce({ + FunctionError: "Unhandled", + Payload: encodeResult({ statusCode: 500, headers: {}, body: "" }), + }); + + await expect(client.post("/some-path", {})).rejects.toEqual( + expect.objectContaining>({ name: "HttpError", status: 500 }), + ); + }); + }); + + describe("postRaw", () => { + it("returns a Response with the correct status and body on 2xx", async () => { + mockSend.mockResolvedValueOnce({ + Payload: encodeResult({ statusCode: 200, headers: {}, body: "raw body" }), + }); + + const result = await client.postRaw("/some-path", "payload"); + + expect(result.status).toBe(200); + expect(await result.text()).toBe("raw body"); + }); + + it("throws HttpError with status when response is non-2xx", async () => { + mockSend.mockResolvedValueOnce({ + Payload: encodeResult({ statusCode: 502, headers: {}, body: "bad gateway" }), + }); + + await expect(client.postRaw("/some-path", "payload")).rejects.toEqual( + expect.objectContaining>({ + name: "HttpError", + status: 502, + }), + ); + }); + + it("throws HttpError 500 when FunctionError is set", async () => { + mockSend.mockResolvedValueOnce({ + FunctionError: "Unhandled", + Payload: encodeResult({ statusCode: 500, headers: {}, body: "" }), + }); + + await expect(client.postRaw("/some-path", "payload")).rejects.toEqual( + expect.objectContaining>({ name: "HttpError", status: 500 }), + ); + }); + }); +}); diff --git a/lambdas/src/lib/http/lambda-http-client.ts b/lambdas/src/lib/http/lambda-http-client.ts new file mode 100644 index 000000000..8f6dc7105 --- /dev/null +++ b/lambdas/src/lib/http/lambda-http-client.ts @@ -0,0 +1,160 @@ +import { InvokeCommand, LambdaClient } from "@aws-sdk/client-lambda"; +import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; + +import { HttpClient, HttpError } from "./http-client"; + +const buildApiGatewayEvent = ( + method: "GET" | "POST", + path: string, + headers: Record, + body: string | null, +): Partial => ({ + httpMethod: method, + path, + headers, + body, + multiValueHeaders: {}, + queryStringParameters: null, + multiValueQueryStringParameters: null, + pathParameters: null, + stageVariables: null, + requestContext: {} as APIGatewayProxyEvent["requestContext"], + resource: "", + isBase64Encoded: false, +}); + +const decodePayload = (payload: Uint8Array | undefined): APIGatewayProxyResult => { + if (!payload) { + throw new HttpError("Lambda invocation returned no payload", 500); + } + return JSON.parse(Buffer.from(payload).toString("utf-8")) as APIGatewayProxyResult; +}; + +const serializeBody = (body: unknown): string | null => { + if (body == null) return null; + if (typeof body === "string") return body; + return JSON.stringify(body); +}; + +export class LambdaHttpClient implements HttpClient { + constructor( + private readonly client: LambdaClient, + private readonly functionName: string, + ) {} + + async get(url: string, headers?: Record): Promise { + const event = buildApiGatewayEvent( + "GET", + url, + { Accept: "application/json", ...headers }, + null, + ); + + const command = new InvokeCommand({ + FunctionName: this.functionName, + InvocationType: "RequestResponse", + Payload: JSON.stringify(event), + }); + + const response = await this.client.send(command); + + if (response.FunctionError) { + throw new HttpError(`Error sending request to ${this.functionName} ${url}`, 500); + } + + const result = decodePayload(response.Payload); + + const status = result.statusCode; + const body = result.body; + + if (status < 200 || status >= 300) { + throw new HttpError( + `HTTP GET: Error response from ${this.functionName} ${url}: ${body}`, + status, + ); + } + + return JSON.parse(body) as T; + } + + async post( + url: string, + body: unknown, + headers: Record = {}, + contentType: string = "application/json", + ): Promise { + const event = buildApiGatewayEvent( + "POST", + url, + { Accept: "application/json", "Content-Type": contentType, ...headers }, + serializeBody(body), + ); + + const command = new InvokeCommand({ + FunctionName: this.functionName, + InvocationType: "RequestResponse", + Payload: JSON.stringify(event), + }); + + const response = await this.client.send(command); + + if (response.FunctionError) { + throw new HttpError(`Error sending request to ${this.functionName} ${url}`, 500); + } + + const result = decodePayload(response.Payload); + + const statusCode = result.statusCode; + const resultBody = result.body; + if (statusCode < 200 || statusCode >= 300) { + throw new HttpError( + `HTTP POST: Error response from ${this.functionName} ${url}: ${resultBody}`, + statusCode, + ); + } + + return JSON.parse(resultBody) as T; + } + + async postRaw( + url: string, + body: unknown, + headers: Record = {}, + contentType: string = "application/json", + ): Promise { + const event = buildApiGatewayEvent( + "POST", + url, + { Accept: "application/json", "Content-Type": contentType, ...headers }, + serializeBody(body), + ); + + const command = new InvokeCommand({ + FunctionName: this.functionName, + InvocationType: "RequestResponse", + Payload: JSON.stringify(event), + }); + + const response = await this.client.send(command); + + if (response.FunctionError) { + throw new HttpError(`Error sending request to ${this.functionName} ${url}`, 500); + } + + const result = decodePayload(response.Payload); + + const statusCode = result.statusCode; + const resultBody = result.body; + if (statusCode < 200 || statusCode >= 300) { + throw new HttpError( + `HTTP POST: Error response from ${this.functionName} ${url}: ${resultBody}`, + statusCode, + ); + } + + return new Response(resultBody, { + status: statusCode, + headers: result.headers as Record, + }); + } +} From a545e5fe8e0053315750ea947d267d618c5a7052 Mon Sep 17 00:00:00 2001 From: lewisbirks Date: Mon, 20 Apr 2026 17:22:28 +0100 Subject: [PATCH 05/30] docs: refine lambda instructions Signed-off-by: lewisbirks --- .github/instructions/lambdas.instructions.md | 47 ++++++++++++++++---- 1 file changed, 39 insertions(+), 8 deletions(-) diff --git a/.github/instructions/lambdas.instructions.md b/.github/instructions/lambdas.instructions.md index f2b663d63..2be593b06 100644 --- a/.github/instructions/lambdas.instructions.md +++ b/.github/instructions/lambdas.instructions.md @@ -31,8 +31,42 @@ lambdas/src/ ## Handler Pattern +Lambdas can be invoked via a variety of methods: + +- SQS message +- Scheduled event +- API Gateway HTTP API +- Manual invocation from another lambda + +Lambda handlers are wrapped in Middy middleware to add common functionality. + Use this as the preferred structure for new lambdas: +```typescript +import middy from "@middy/core"; + +import { init } from "./init"; + +export const lambdaHandler = async (event: unknown): Promise => { + const { myService } = await init(); + // parse + validate request with Zod + // call services + // return response if needed +}; + +export const handler = middy(lambdaHandler); +``` + +Key rules: + +- The internal function is always `lambdaHandler` (named export). +- The Middy-wrapped export is always `handler` (the actual Lambda entrypoint). +- Import `init` at module scope, but call `init()` inside `lambdaHandler`. `init()` must be singleton-cached, so dependencies are still constructed once per cold start. + +### HTTP API Gateway + +When a lambda is invoked via the HTTP API, wrap the handler with the following Middy middleware in this order: + ```typescript import middy from "@middy/core"; import cors from "@middy/http-cors"; @@ -55,6 +89,7 @@ export const lambdaHandler = async ( // parse + validate request with Zod // call services console.info(name, "Request received", { correlationId }); + // return response return createJsonResponse(200, { result }, { "X-Correlation-ID": correlationId }); }; @@ -64,13 +99,10 @@ export const handler = middy(lambdaHandler) .use(httpErrorHandler()); ``` -Key rules: - -- The internal function is always `lambdaHandler` (named export). -- The Middy-wrapped export is always `handler` (the actual Lambda entrypoint). - Middy middleware order is always: `httpSecurityHeaders` → `cors` → `httpErrorHandler`. + - `cors` is only required if the http request is coming from a browser. + - `httpSecurityHeaders` and `httpErrorHandler` is always required. - Always pass the shared config objects: `httpSecurityHeaders(securityHeaders)` and `cors(defaultCorsOptions)` — never inline options. -- Import `init` at module scope, but call `init()` inside `lambdaHandler`. `init()` must be singleton-cached, so dependencies are still constructed once per cold start. - Echo `X-Correlation-ID` in the response header for all JSON responses. FHIR-response lambdas (using `createFhirResponse`) do not set this header — that is an accepted divergence. - Use `createJsonResponse(statusCode, body, extraHeaders?)` for all JSON responses — never construct the response object manually. - For lambdas that return FHIR resources, use `createFhirResponse` / `createFhirErrorResponse` from `../lib/fhir-response` instead of `createJsonResponse`. @@ -191,9 +223,8 @@ if (!parsed.success) { ## HTTP Client -For any outbound HTTP calls from a lambda, always use `FetchHttpClient` (which implements the -`HttpClient` interface) from `../lib/http/http-client`. Never use `axios`, `undici` directly, -or bare `fetch` in handler or service code. +For any outbound HTTP calls from a lambda, always use an implementation of `HttpClient` from `../lib/http/http-client`. The implementation of that `HttpClient` can be use-case specific, it might be the `FetchHttpClient` or it might be another implementation. +Never use `axios`, `undici` directly, or bare `fetch` in handler or service code. ```typescript import { FetchHttpClient, type HttpClient } from "../lib/http/http-client"; From ea6ee3752996b9b13213c1c097869b46050eb2f4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Thu, 23 Apr 2026 11:10:17 +0200 Subject: [PATCH 06/30] feat: update business status from CONFIRMED to ORDER_ACCEPTED in order status handling --- lambdas/src/order-status-lambda/index.test.ts | 8 ++++---- lambdas/src/order-status-lambda/types.ts | 2 +- lambdas/src/order-status-lambda/utils.ts | 2 +- 3 files changed, 6 insertions(+), 6 deletions(-) diff --git a/lambdas/src/order-status-lambda/index.test.ts b/lambdas/src/order-status-lambda/index.test.ts index 1237fda4d..6a1cb0fc8 100644 --- a/lambdas/src/order-status-lambda/index.test.ts +++ b/lambdas/src/order-status-lambda/index.test.ts @@ -258,10 +258,10 @@ describe("Order Status Lambda Handler", () => { expect(result.statusCode).toBe(201); }); - it(`should accept ${IncomingBusinessStatus.CONFIRMED} business status`, async () => { + it(`should accept ${IncomingBusinessStatus.ORDER_ACCEPTED} business status`, async () => { mockEvent.body = JSON.stringify({ ...validTaskBody, - businessStatus: { text: IncomingBusinessStatus.CONFIRMED }, + businessStatus: { text: IncomingBusinessStatus.ORDER_ACCEPTED }, } satisfies Partial); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); @@ -460,7 +460,7 @@ describe("Order Status Lambda Handler", () => { mockEvent.body = JSON.stringify({ ...validTaskBody, businessStatus: { - text: IncomingBusinessStatus.CONFIRMED, + text: IncomingBusinessStatus.ORDER_ACCEPTED, }, } satisfies Partial); @@ -469,7 +469,7 @@ describe("Order Status Lambda Handler", () => { expect(result.statusCode).toBe(201); expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ - statusCode: businessStatusMapping[IncomingBusinessStatus.CONFIRMED], + statusCode: businessStatusMapping[IncomingBusinessStatus.ORDER_ACCEPTED], }), ); }); diff --git a/lambdas/src/order-status-lambda/types.ts b/lambdas/src/order-status-lambda/types.ts index 08a511053..d43d366bd 100644 --- a/lambdas/src/order-status-lambda/types.ts +++ b/lambdas/src/order-status-lambda/types.ts @@ -1,5 +1,5 @@ export enum IncomingBusinessStatus { - CONFIRMED = "confirmed", + ORDER_ACCEPTED = "order-accepted", DISPATCHED = "dispatched", RECEIVED_AT_LAB = "received-at-lab", } diff --git a/lambdas/src/order-status-lambda/utils.ts b/lambdas/src/order-status-lambda/utils.ts index 5adcd16e1..a1ff167d6 100644 --- a/lambdas/src/order-status-lambda/utils.ts +++ b/lambdas/src/order-status-lambda/utils.ts @@ -16,7 +16,7 @@ export const businessStatusMapping: Record< IncomingBusinessStatus, AllowedInternalBusinessStatuses > = { - [IncomingBusinessStatus.CONFIRMED]: AllowedInternalBusinessStatuses.CONFIRMED, + [IncomingBusinessStatus.ORDER_ACCEPTED]: AllowedInternalBusinessStatuses.CONFIRMED, [IncomingBusinessStatus.DISPATCHED]: AllowedInternalBusinessStatuses.DISPATCHED, [IncomingBusinessStatus.RECEIVED_AT_LAB]: AllowedInternalBusinessStatuses.RECEIVED, }; From c3c78b45c8beb38184f7da521b5234306210c865 Mon Sep 17 00:00:00 2001 From: BenKainos-43 Date: Thu, 23 Apr 2026 12:02:01 +0100 Subject: [PATCH 07/30] feat: hiv-result-processor - extract correlation ID from headers --- lambdas/src/hiv-result-processor-lambda/index.ts | 14 +++++++++++--- .../hiv-result-processor-lambda/task-builder.ts | 6 +++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/lambdas/src/hiv-result-processor-lambda/index.ts b/lambdas/src/hiv-result-processor-lambda/index.ts index 5c9b9428a..7108786cc 100644 --- a/lambdas/src/hiv-result-processor-lambda/index.ts +++ b/lambdas/src/hiv-result-processor-lambda/index.ts @@ -6,6 +6,7 @@ import { init } from "./init"; import { InterpretationCode } from "./models"; import { buildTaskFromObservation } from "./task-builder"; import { extractInterpretationCodeFromFHIRObservation } from "./validation-service"; +import { getCorrelationIdFromEventHeaders } from "../lib/utils/utils"; const { commons, resultStatusLambdaService } = init(); @@ -15,6 +16,13 @@ export const handler = async (event: APIGatewayProxyEvent): Promise Date: Thu, 23 Apr 2026 12:30:18 +0100 Subject: [PATCH 08/30] refactor: add types and simplify error handling - Import extraction functions from validation-service instead of duplicating - Type buildTaskFromObservation() return as FHIRTask - Type sendResult() parameter as FHIRTask and remove error handling (let caller handle it) --- .../src/hiv-result-processor-lambda/index.ts | 2 - .../result-status-lambda-service.ts | 47 ++++--------------- .../task-builder.ts | 45 ++++++------------ 3 files changed, 24 insertions(+), 70 deletions(-) diff --git a/lambdas/src/hiv-result-processor-lambda/index.ts b/lambdas/src/hiv-result-processor-lambda/index.ts index 7108786cc..359c81152 100644 --- a/lambdas/src/hiv-result-processor-lambda/index.ts +++ b/lambdas/src/hiv-result-processor-lambda/index.ts @@ -44,8 +44,6 @@ export const handler = async (event: APIGatewayProxyEvent): Promise { - // check what the return type is, and convert to OperationOutcome if needed, - // this will be the type returned in HOTE-1100 - try { - await this.client.post( - "result/status", - result, - { "X-Correlation-Id": correlationId ?? "null" }, - "application/fhir+json", - ); - // TODO: Do we need to return anything here? Is it the responsibility of this code to generate - // the operation outcome or should that be higher up. - // Or maybe we don't catch the error here and let that propagate up? - return { - resourceType: "OperationOutcome", - issue: [ - { - severity: "information", - code: "informational", - diagnostics: "Status update lambda invoked successfully", - }, - ], - }; - } catch (error: unknown) { - return { - resourceType: "OperationOutcome", - issue: [ - { - severity: "fatal", - code: "exception", - diagnostics: `Failed to invoke status lambda: ${(error as Error).message}`, - }, - ], - }; - } + async sendResult(result: FHIRTask, correlationId?: string): Promise { + await this.client.post( + "result/status", + result, + { "X-Correlation-Id": correlationId ?? "null" }, + "application/fhir+json", + ); } -} +} \ No newline at end of file diff --git a/lambdas/src/hiv-result-processor-lambda/task-builder.ts b/lambdas/src/hiv-result-processor-lambda/task-builder.ts index fce4f9efb..6dd8d5bf0 100644 --- a/lambdas/src/hiv-result-processor-lambda/task-builder.ts +++ b/lambdas/src/hiv-result-processor-lambda/task-builder.ts @@ -4,36 +4,21 @@ // Returns the Task objects import { Observation } from "fhir/r4"; -import { InterpretationCode } from "./models"; -import { extractInterpretationCodeFromFHIRObservation } from "./validation-service"; +import { type FHIRTask } from "../lib/models/fhir/fhir-service-request-type"; +import { + extractInterpretationCodeFromFHIRObservation, + extractOrderUidFromFHIRObservation, + extractPatientIdFromFHIRObservation, + extractSupplierIdFromFHIRObservation, +} from "./validation-service"; -// Helper functions to extract identifiers from Observation -// TODO do helper functions like this already exist? -function extractOrderUid(observation: Observation): string { - const reference = observation.basedOn?.[0]?.reference; - if (!reference) throw new Error("Missing basedOn reference"); - return reference.split("/")[1]; -} - -function extractPatientId(observation: Observation): string { - const reference = observation.subject?.reference; - if (!reference) throw new Error("Missing subject reference"); - return reference.split("/")[1]; -} - -function extractSupplierId(observation: Observation): string { - const reference = observation.performer?.[0]?.reference; - if (!reference) throw new Error("Missing performer reference"); - return reference.split("/")[1]; -} - -// Build the FHIR Task payload for the status lambda -// TODO what type is the result? -// This will help inform what should be passed to the result-status-lambda-service -export function buildTaskFromObservation(observation: Observation, correlationId: string ) { - const orderUid = extractOrderUid(observation); - const patientId = extractPatientId(observation); - const supplierId = extractSupplierId(observation); +export function buildTaskFromObservation( + observation: Observation, + correlationId: string, +): FHIRTask { + const orderUid = extractOrderUidFromFHIRObservation(observation); + const patientId = extractPatientIdFromFHIRObservation(observation); + const supplierId = extractSupplierIdFromFHIRObservation(observation); const now = new Date().toISOString(); @@ -76,4 +61,4 @@ export function buildTaskFromObservation(observation: Observation, correlationId authoredOn: now, lastModified: now, }; -} +} \ No newline at end of file From 9d1aee3368da23793305834ca8b6f0df4008a55b Mon Sep 17 00:00:00 2001 From: BenKainos-43 Date: Thu, 23 Apr 2026 14:09:28 +0100 Subject: [PATCH 09/30] Fixed pre-commit issues, mostly order of imports and http changed to HTTP --- .github/instructions/lambdas.instructions.md | 2 +- lambdas/src/hiv-result-processor-lambda/index.ts | 4 ++-- .../result-status-lambda-service.ts | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/instructions/lambdas.instructions.md b/.github/instructions/lambdas.instructions.md index 2be593b06..8fa60770a 100644 --- a/.github/instructions/lambdas.instructions.md +++ b/.github/instructions/lambdas.instructions.md @@ -100,7 +100,7 @@ export const handler = middy(lambdaHandler) ``` - Middy middleware order is always: `httpSecurityHeaders` → `cors` → `httpErrorHandler`. - - `cors` is only required if the http request is coming from a browser. + - `cors` is only required if the HTTP request is coming from a browser. - `httpSecurityHeaders` and `httpErrorHandler` is always required. - Always pass the shared config objects: `httpSecurityHeaders(securityHeaders)` and `cors(defaultCorsOptions)` — never inline options. - Echo `X-Correlation-ID` in the response header for all JSON responses. FHIR-response lambdas (using `createFhirResponse`) do not set this header — that is an accepted divergence. diff --git a/lambdas/src/hiv-result-processor-lambda/index.ts b/lambdas/src/hiv-result-processor-lambda/index.ts index 359c81152..b09be6875 100644 --- a/lambdas/src/hiv-result-processor-lambda/index.ts +++ b/lambdas/src/hiv-result-processor-lambda/index.ts @@ -2,11 +2,11 @@ import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; import { Observation } from "fhir/r4"; import { createFhirErrorResponse, createFhirResponse } from "../lib/fhir-response"; +import { getCorrelationIdFromEventHeaders } from "../lib/utils/utils"; import { init } from "./init"; import { InterpretationCode } from "./models"; import { buildTaskFromObservation } from "./task-builder"; import { extractInterpretationCodeFromFHIRObservation } from "./validation-service"; -import { getCorrelationIdFromEventHeaders } from "../lib/utils/utils"; const { commons, resultStatusLambdaService } = init(); @@ -45,7 +45,7 @@ export const handler = async (event: APIGatewayProxyEvent): Promise Date: Thu, 23 Apr 2026 14:26:02 +0100 Subject: [PATCH 10/30] refactor: complete hiv-result-processor todos and fix tests --- .../hiv-result-processor-lambda/index.test.ts | 9 ++- .../result-status-lambda-service.test.ts | 72 +++++-------------- .../task-builder.test.ts | 43 ++++++----- 3 files changed, 49 insertions(+), 75 deletions(-) diff --git a/lambdas/src/hiv-result-processor-lambda/index.test.ts b/lambdas/src/hiv-result-processor-lambda/index.test.ts index 02609b9eb..fe41aeeb2 100644 --- a/lambdas/src/hiv-result-processor-lambda/index.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/index.test.ts @@ -56,7 +56,7 @@ describe("hiv-results-processor handler", () => { path: "/hiv", httpMethod: "POST", body: JSON.stringify(observation), - headers: {}, + headers: { "X-Correlation-Id": "550e8400-e29b-41d4-a716-446655440003" }, isBase64Encoded: false, multiValueHeaders: {}, multiValueQueryStringParameters: null, @@ -94,8 +94,11 @@ describe("hiv-results-processor handler", () => { const res = await handler(event); - expect(buildTaskFromObservation).toHaveBeenCalledWith(observation); - expect(initMock.resultStatusLambdaService.sendResult).toHaveBeenCalledWith({ mockTask: true }); + expect(buildTaskFromObservation).toHaveBeenCalledWith(observation, event.headers["X-Correlation-Id"]); + expect(initMock.resultStatusLambdaService.sendResult).toHaveBeenCalledWith( + { mockTask: true }, + event.headers["X-Correlation-Id"], + ); expect(res.statusCode).toBe(200); }); diff --git a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts b/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts index 50e5aebd9..de9a0b91e 100644 --- a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts @@ -1,6 +1,6 @@ -import { Task } from "fhir/r4"; +import { type FHIRTask } from "../lib/models/fhir/fhir-service-request-type"; -import { HttpClient, HttpError } from "../lib/http/http-client"; +import { HttpClient } from "../lib/http/http-client"; import { ResultStatusLambdaService } from "./result-status-lambda-service"; const mockPost = jest.fn(); @@ -11,10 +11,18 @@ const mockHttpClient: HttpClient = { }; describe("ResultStatusLambdaService", () => { - const taskPayload: Task = { + const taskPayload: FHIRTask = { resourceType: "Task", status: "completed", intent: "order", + identifier: [ + { + system: "https://fhir.hometest.nhs.uk/Id/order-id", + value: "550e8400-e29b-41d4-a716-446655440000", + }, + ], + basedOn: [{ reference: "ServiceRequest/550e8400-e29b-41d4-a716-446655440000" }], + for: { reference: "Patient/550e8400-e29b-41d4-a716-446655440001" }, }; const correlationId = "test-correlation-id"; @@ -38,7 +46,7 @@ describe("ResultStatusLambdaService", () => { }); it("uses 'null' as correlation ID when none is provided", async () => { - mockPost.mockResolvedValueOnce({}); + mockPost.mockResolvedValueOnce(undefined); const service = new ResultStatusLambdaService(mockHttpClient); await service.sendResult(taskPayload); @@ -51,57 +59,13 @@ describe("ResultStatusLambdaService", () => { ); }); - it("returns a success OperationOutcome when the post succeeds", async () => { - mockPost.mockResolvedValueOnce({}); - const service = new ResultStatusLambdaService(mockHttpClient); - - const result = await service.sendResult(taskPayload, correlationId); - - expect(result).toEqual({ - resourceType: "OperationOutcome", - issue: [ - { - severity: "information", - code: "informational", - diagnostics: "Status update lambda invoked successfully", - }, - ], - }); - }); - - it("returns an OperationOutcome when the post throws an HttpError", async () => { - mockPost.mockRejectedValueOnce(new HttpError("Not found", 404, "not found")); + it("throws an error when the post fails", async () => { + const error = new Error("Network failure"); + mockPost.mockRejectedValueOnce(error); const service = new ResultStatusLambdaService(mockHttpClient); - const result = await service.sendResult(taskPayload, correlationId); - - expect(result).toEqual({ - resourceType: "OperationOutcome", - issue: [ - { - severity: "fatal", - code: "exception", - diagnostics: "Failed to invoke status lambda: Not found", - }, - ], - }); - }); - - it("returns an OperationOutcome when the post throws an unexpected error", async () => { - mockPost.mockRejectedValueOnce(new Error("network failure")); - const service = new ResultStatusLambdaService(mockHttpClient); - - const result = await service.sendResult(taskPayload, correlationId); - - expect(result).toEqual({ - resourceType: "OperationOutcome", - issue: [ - { - severity: "fatal", - code: "exception", - diagnostics: `Failed to invoke status lambda: network failure`, - }, - ], - }); + await expect(service.sendResult(taskPayload, correlationId)).rejects.toThrow( + "Network failure", + ); }); }); diff --git a/lambdas/src/hiv-result-processor-lambda/task-builder.test.ts b/lambdas/src/hiv-result-processor-lambda/task-builder.test.ts index 873f7c23b..8103f67d5 100644 --- a/lambdas/src/hiv-result-processor-lambda/task-builder.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/task-builder.test.ts @@ -4,6 +4,7 @@ import { buildTaskFromObservation } from "./task-builder"; describe("buildTaskFromObservation", () => { const fixedDate = new Date("2026-04-17T10:20:30.000Z"); + const correlationId = "test-correlation-id"; const baseObservation: Pick = { resourceType: "Observation", status: "final", @@ -28,34 +29,38 @@ describe("buildTaskFromObservation", () => { it("builds the expected task from a valid observation", () => { const observation: Observation = { ...baseObservation, - basedOn: [{ reference: "ServiceRequest/order-123" }], - subject: { reference: "Patient/patient-456" }, - performer: [{ reference: "Organization/supplier-789" }], + basedOn: [{ reference: "ServiceRequest/550e8400-e29b-41d4-a716-446655440000" }], + subject: { reference: "Patient/550e8400-e29b-41d4-a716-446655440001" }, + performer: [{ reference: "Organization/550e8400-e29b-41d4-a716-446655440002" }], }; - const result = buildTaskFromObservation(observation) as Task; + const result = buildTaskFromObservation(observation, correlationId) as Task; expect(result).toEqual({ resourceType: "Task", identifier: [ { system: "https://fhir.hometest.nhs.uk/Id/order-id", - value: "order-123", + value: "550e8400-e29b-41d4-a716-446655440000", + }, + { + system: "https://fhir.hometest.nhs.uk/Id/correlation-id", + value: correlationId, }, ], status: "completed", intent: "order", basedOn: [ { - reference: "ServiceRequest/order-123", + reference: "ServiceRequest/550e8400-e29b-41d4-a716-446655440000", type: "ServiceRequest", }, ], requester: { - reference: "Organization/supplier-789", + reference: "Organization/550e8400-e29b-41d4-a716-446655440002", }, for: { - reference: "Patient/patient-456", + reference: "Patient/550e8400-e29b-41d4-a716-446655440001", }, businessStatus: { coding: [ @@ -75,30 +80,32 @@ describe("buildTaskFromObservation", () => { it("throws when basedOn reference is missing", () => { const observation: Observation = { ...baseObservation, - subject: { reference: "Patient/patient-456" }, - performer: [{ reference: "Organization/supplier-789" }], + subject: { reference: "Patient/550e8400-e29b-41d4-a716-446655440001" }, + performer: [{ reference: "Organization/550e8400-e29b-41d4-a716-446655440002" }], }; - expect(() => buildTaskFromObservation(observation)).toThrow("Missing basedOn reference"); + expect(() => buildTaskFromObservation(observation, correlationId)).toThrow("Observation.basedOn[0].reference is missing"); }); it("throws when subject reference is missing", () => { const observation: Observation = { ...baseObservation, - basedOn: [{ reference: "ServiceRequest/order-123" }], - performer: [{ reference: "Organization/supplier-789" }], + basedOn: [{ reference: "ServiceRequest/550e8400-e29b-41d4-a716-446655440000" }], + subject: { reference: "" }, + performer: [{ reference: "Organization/550e8400-e29b-41d4-a716-446655440002" }], }; - expect(() => buildTaskFromObservation(observation)).toThrow("Missing subject reference"); + expect(() => buildTaskFromObservation(observation, correlationId)).toThrow("Invalid subject reference format"); }); it("throws when performer reference is missing", () => { const observation: Observation = { ...baseObservation, - basedOn: [{ reference: "ServiceRequest/order-123" }], - subject: { reference: "Patient/patient-456" }, + basedOn: [{ reference: "ServiceRequest/550e8400-e29b-41d4-a716-446655440000" }], + subject: { reference: "Patient/550e8400-e29b-41d4-a716-446655440001" }, + performer: [{ reference: "" }], }; - expect(() => buildTaskFromObservation(observation)).toThrow("Missing performer reference"); + expect(() => buildTaskFromObservation(observation, correlationId)).toThrow("Invalid performer reference format"); }); -}); +}); \ No newline at end of file From fe4e32d9fb9a0935732eab2682fb3ae52b9cbcdc Mon Sep 17 00:00:00 2001 From: BenKainos-43 Date: Thu, 23 Apr 2026 14:35:03 +0100 Subject: [PATCH 11/30] Fix: prettier formatter --- .../src/hiv-result-processor-lambda/index.test.ts | 5 ++++- .../result-status-lambda-service.test.ts | 7 ++----- .../result-status-lambda-service.ts | 3 ++- .../task-builder.test.ts | 14 ++++++++++---- .../hiv-result-processor-lambda/task-builder.ts | 2 +- 5 files changed, 19 insertions(+), 12 deletions(-) diff --git a/lambdas/src/hiv-result-processor-lambda/index.test.ts b/lambdas/src/hiv-result-processor-lambda/index.test.ts index fe41aeeb2..2af3ace48 100644 --- a/lambdas/src/hiv-result-processor-lambda/index.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/index.test.ts @@ -94,7 +94,10 @@ describe("hiv-results-processor handler", () => { const res = await handler(event); - expect(buildTaskFromObservation).toHaveBeenCalledWith(observation, event.headers["X-Correlation-Id"]); + expect(buildTaskFromObservation).toHaveBeenCalledWith( + observation, + event.headers["X-Correlation-Id"], + ); expect(initMock.resultStatusLambdaService.sendResult).toHaveBeenCalledWith( { mockTask: true }, event.headers["X-Correlation-Id"], diff --git a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts b/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts index de9a0b91e..f39bd41af 100644 --- a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts @@ -1,6 +1,5 @@ -import { type FHIRTask } from "../lib/models/fhir/fhir-service-request-type"; - import { HttpClient } from "../lib/http/http-client"; +import { type FHIRTask } from "../lib/models/fhir/fhir-service-request-type"; import { ResultStatusLambdaService } from "./result-status-lambda-service"; const mockPost = jest.fn(); @@ -64,8 +63,6 @@ describe("ResultStatusLambdaService", () => { mockPost.mockRejectedValueOnce(error); const service = new ResultStatusLambdaService(mockHttpClient); - await expect(service.sendResult(taskPayload, correlationId)).rejects.toThrow( - "Network failure", - ); + await expect(service.sendResult(taskPayload, correlationId)).rejects.toThrow("Network failure"); }); }); diff --git a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts b/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts index f079a03da..2c8493e5c 100644 --- a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts +++ b/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts @@ -1,4 +1,5 @@ import { HttpClient } from "src/lib/http/http-client"; + import { type FHIRTask } from "../lib/models/fhir/fhir-service-request-type"; export class ResultStatusLambdaService { @@ -12,4 +13,4 @@ export class ResultStatusLambdaService { "application/fhir+json", ); } -} \ No newline at end of file +} diff --git a/lambdas/src/hiv-result-processor-lambda/task-builder.test.ts b/lambdas/src/hiv-result-processor-lambda/task-builder.test.ts index 8103f67d5..29109a2e9 100644 --- a/lambdas/src/hiv-result-processor-lambda/task-builder.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/task-builder.test.ts @@ -84,7 +84,9 @@ describe("buildTaskFromObservation", () => { performer: [{ reference: "Organization/550e8400-e29b-41d4-a716-446655440002" }], }; - expect(() => buildTaskFromObservation(observation, correlationId)).toThrow("Observation.basedOn[0].reference is missing"); + expect(() => buildTaskFromObservation(observation, correlationId)).toThrow( + "Observation.basedOn[0].reference is missing", + ); }); it("throws when subject reference is missing", () => { @@ -95,7 +97,9 @@ describe("buildTaskFromObservation", () => { performer: [{ reference: "Organization/550e8400-e29b-41d4-a716-446655440002" }], }; - expect(() => buildTaskFromObservation(observation, correlationId)).toThrow("Invalid subject reference format"); + expect(() => buildTaskFromObservation(observation, correlationId)).toThrow( + "Invalid subject reference format", + ); }); it("throws when performer reference is missing", () => { @@ -106,6 +110,8 @@ describe("buildTaskFromObservation", () => { performer: [{ reference: "" }], }; - expect(() => buildTaskFromObservation(observation, correlationId)).toThrow("Invalid performer reference format"); + expect(() => buildTaskFromObservation(observation, correlationId)).toThrow( + "Invalid performer reference format", + ); }); -}); \ No newline at end of file +}); diff --git a/lambdas/src/hiv-result-processor-lambda/task-builder.ts b/lambdas/src/hiv-result-processor-lambda/task-builder.ts index 6dd8d5bf0..9a24aca33 100644 --- a/lambdas/src/hiv-result-processor-lambda/task-builder.ts +++ b/lambdas/src/hiv-result-processor-lambda/task-builder.ts @@ -61,4 +61,4 @@ export function buildTaskFromObservation( authoredOn: now, lastModified: now, }; -} \ No newline at end of file +} From f22eebabc9181fc762b58aebb3519eca1b6e78a4 Mon Sep 17 00:00:00 2001 From: BenKainos-43 Date: Thu, 23 Apr 2026 15:11:25 +0100 Subject: [PATCH 12/30] Refactor: SonarQube code duplication fixes --- .../task-builder.ts | 1 - .../hiv-result-processor-lambda/validation.ts | 43 ++++--------------- lambdas/src/lib/validation/index.ts | 38 ++++++++++++++++ lambdas/src/order-result-lambda/validation.ts | 43 ++++--------------- 4 files changed, 54 insertions(+), 71 deletions(-) create mode 100644 lambdas/src/lib/validation/index.ts diff --git a/lambdas/src/hiv-result-processor-lambda/task-builder.ts b/lambdas/src/hiv-result-processor-lambda/task-builder.ts index 9a24aca33..108cc4ebc 100644 --- a/lambdas/src/hiv-result-processor-lambda/task-builder.ts +++ b/lambdas/src/hiv-result-processor-lambda/task-builder.ts @@ -6,7 +6,6 @@ import { Observation } from "fhir/r4"; import { type FHIRTask } from "../lib/models/fhir/fhir-service-request-type"; import { - extractInterpretationCodeFromFHIRObservation, extractOrderUidFromFHIRObservation, extractPatientIdFromFHIRObservation, extractSupplierIdFromFHIRObservation, diff --git a/lambdas/src/hiv-result-processor-lambda/validation.ts b/lambdas/src/hiv-result-processor-lambda/validation.ts index de78b9c18..488ccd4d8 100644 --- a/lambdas/src/hiv-result-processor-lambda/validation.ts +++ b/lambdas/src/hiv-result-processor-lambda/validation.ts @@ -1,35 +1,8 @@ -import { ErrorStatusCode } from "../lib/fhir-response"; - -export interface ValidationError { - errorCode: ErrorStatusCode; - errorType: "not-found" | "invalid" | "forbidden" | "conflict"; - errorMessage: string; - severity: "error" | "warning" | "information"; -} -export type ValidationResult = ValidationResultSuccess | ValidationResultError; -export type ValidationResultSuccess = { - success: true; - data: T; - error?: never; -}; -export type ValidationResultError = { - success: false; - data?: never; - error: ValidationError; -}; - -export function successResult(): ValidationResult; -export function successResult(data: T): ValidationResult; -export function successResult(data?: T): ValidationResult { - return { - success: true, - data: data as T, - }; -} - -export function errorResult(error: ValidationError): ValidationResultError { - return { - success: false, - error, - }; -} +export { + ValidationError, + ValidationResult, + ValidationResultSuccess, + ValidationResultError, + successResult, + errorResult, +} from "../lib/validation"; diff --git a/lambdas/src/lib/validation/index.ts b/lambdas/src/lib/validation/index.ts new file mode 100644 index 000000000..6693ee2c3 --- /dev/null +++ b/lambdas/src/lib/validation/index.ts @@ -0,0 +1,38 @@ +import { ErrorStatusCode } from "../fhir-response"; + +export interface ValidationError { + errorCode: ErrorStatusCode; + errorType: "not-found" | "invalid" | "forbidden" | "conflict"; + errorMessage: string; + severity: "error" | "warning" | "information"; +} + +export type ValidationResult = ValidationResultSuccess | ValidationResultError; + +export type ValidationResultSuccess = { + success: true; + data: T; + error?: never; +}; + +export type ValidationResultError = { + success: false; + data?: never; + error: ValidationError; +}; + +export function successResult(): ValidationResult; +export function successResult(data: T): ValidationResult; +export function successResult(data?: T): ValidationResult { + return { + success: true, + data: data as T, + }; +} + +export function errorResult(error: ValidationError): ValidationResultError { + return { + success: false, + error, + }; +} diff --git a/lambdas/src/order-result-lambda/validation.ts b/lambdas/src/order-result-lambda/validation.ts index de78b9c18..488ccd4d8 100644 --- a/lambdas/src/order-result-lambda/validation.ts +++ b/lambdas/src/order-result-lambda/validation.ts @@ -1,35 +1,8 @@ -import { ErrorStatusCode } from "../lib/fhir-response"; - -export interface ValidationError { - errorCode: ErrorStatusCode; - errorType: "not-found" | "invalid" | "forbidden" | "conflict"; - errorMessage: string; - severity: "error" | "warning" | "information"; -} -export type ValidationResult = ValidationResultSuccess | ValidationResultError; -export type ValidationResultSuccess = { - success: true; - data: T; - error?: never; -}; -export type ValidationResultError = { - success: false; - data?: never; - error: ValidationError; -}; - -export function successResult(): ValidationResult; -export function successResult(data: T): ValidationResult; -export function successResult(data?: T): ValidationResult { - return { - success: true, - data: data as T, - }; -} - -export function errorResult(error: ValidationError): ValidationResultError { - return { - success: false, - error, - }; -} +export { + ValidationError, + ValidationResult, + ValidationResultSuccess, + ValidationResultError, + successResult, + errorResult, +} from "../lib/validation"; From f5e93293f760eb6abba6cbbb8828dac5fb250d44 Mon Sep 17 00:00:00 2001 From: BenKainos-43 Date: Thu, 23 Apr 2026 15:34:19 +0100 Subject: [PATCH 13/30] Refactor: Removed duplicate code copied from order-result-lambda --- .../validation-service.test.ts | 300 +----------------- .../validation-service.ts | 170 +--------- 2 files changed, 2 insertions(+), 468 deletions(-) diff --git a/lambdas/src/hiv-result-processor-lambda/validation-service.test.ts b/lambdas/src/hiv-result-processor-lambda/validation-service.test.ts index 30f46cfc7..907e705df 100644 --- a/lambdas/src/hiv-result-processor-lambda/validation-service.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/validation-service.test.ts @@ -1,313 +1,15 @@ -import { APIGatewayProxyEvent } from "aws-lambda"; import { Observation } from "fhir/r4"; -import { ConsoleCommons } from "../lib/commons"; -import { ResultStatus } from "../lib/types/status"; import * as utils from "../lib/utils/utils"; -import * as validationUtils from "../lib/utils/validation-utils"; -import { InterpretationCode, orderResultFHIRObservationSchema } from "./models"; +import { InterpretationCode } from "./models"; import * as validation from "./validation-service"; describe("validation-service", () => { - let commons: jest.Mocked; - - beforeEach(() => { - commons = { - logError: jest.fn(), - logInfo: jest.fn(), - logDebug: jest.fn(), - }; - commons.logError.mockReset(); - commons.logInfo.mockReset(); - commons.logDebug.mockReset(); - }); - afterEach(() => { jest.restoreAllMocks(); jest.clearAllMocks(); }); - describe("validateBody", () => { - it("throws error when body is null", () => { - expect(() => validation.validateAndExtractObservation(null, commons)).toThrow( - "Body is empty", - ); - }); - - it("throws error when body is empty object", () => { - expect(() => validation.validateAndExtractObservation("{}", commons)).toThrow( - "Body is empty", - ); - }); - - it("throws error when body is invalid JSON", () => { - expect(() => validation.validateAndExtractObservation("{invalid json}", commons)).toThrow(); - }); - - it("throws error when schema validation fails", () => { - const generateReadableErrorSpy = jest - .spyOn(validationUtils, "generateReadableError") - .mockReturnValue("Invalid schema"); - - const invalidObservation = JSON.stringify({ foo: "bar" }); - jest.spyOn(orderResultFHIRObservationSchema, "safeParse").mockReturnValue({ - success: false, - error: { issues: [{ message: "Invalid schema" }] }, - } as ReturnType); - - expect(() => validation.validateAndExtractObservation(invalidObservation, commons)).toThrow(); - expect(generateReadableErrorSpy).toHaveBeenCalledTimes(1); - }); - - it("does not throw when body is valid and schema passes", () => { - const validObservation = JSON.stringify({ resourceType: "Observation" }); - jest.spyOn(orderResultFHIRObservationSchema, "safeParse").mockReturnValue({ - success: true, - data: { resourceType: "Observation" }, - } as ReturnType); - - expect(() => - validation.validateAndExtractObservation(validObservation, commons), - ).not.toThrow(); - }); - }); - - describe("extractAndValidateObservationFields", () => { - const makeEvent = (body: string | null, headers: Record = {}) => - ({ body, headers }) as unknown as APIGatewayProxyEvent; - - const observation = { - basedOn: [{ reference: "ServiceRequest/550e8400-e29b-41d4-a716-446655440000" }], - subject: { reference: "Patient/550e8400-e29b-41d4-a716-446655440001" }, - performer: [{ reference: "Organization/supplier-123" }], - } as Observation; - - it("returns invalid result when validateBody throws", () => { - jest.spyOn(validation, "validateAndExtractObservation").mockImplementation(() => { - throw new Error("bad body"); - }); - - const result = validation.extractAndValidateObservationFields(makeEvent('{"x":1}'), commons); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toEqual({ - errorCode: 400, - errorType: "invalid", - errorMessage: "bad body", - severity: "error", - }); - } - }); - - it("returns invalid result when correlation header is invalid", () => { - jest.spyOn(validation, "validateAndExtractObservation").mockReturnValue(observation); - jest.spyOn(utils, "getCorrelationIdFromEventHeaders").mockImplementation(() => { - throw new Error("missing correlation id"); - }); - - const result = validation.extractAndValidateObservationFields(makeEvent('{"x":1}'), commons); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toEqual({ - errorCode: 400, - errorType: "invalid", - errorMessage: "missing correlation id", - severity: "error", - }); - } - }); - - it("returns invalid result when identifier extraction fails", () => { - jest.spyOn(validation, "validateAndExtractObservation").mockImplementation(() => { - throw new Error("Unable to extract necessary identifiers from Observation"); - }); - jest.spyOn(utils, "getCorrelationIdFromEventHeaders").mockReturnValue("corr-id"); - jest.spyOn(validation, "extractOrderUidFromFHIRObservation").mockImplementation(() => { - throw new Error("bad order uid"); - }); - - const result = validation.extractAndValidateObservationFields(makeEvent('{"x":1}'), commons); - - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toEqual({ - errorCode: 400, - errorType: "invalid", - errorMessage: "Unable to extract necessary identifiers from Observation", - severity: "error", - }); - } - }); - - it("returns valid result with observation and identifiers on success", () => { - jest.spyOn(validation, "validateAndExtractObservation").mockReturnValue(observation); - jest.spyOn(utils, "getCorrelationIdFromEventHeaders").mockReturnValue("corr-id"); - - const observationEvent = { - basedOn: [{ reference: "ServiceRequest/550e8400-e29b-41d4-a716-446655440000" }], - subject: { reference: "Patient/550e8400-e29b-41d4-a716-446655440001" }, - performer: [{ reference: "Organization/supplier-123" }], - }; - const result = validation.extractAndValidateObservationFields( - makeEvent(JSON.stringify(observationEvent)), - commons, - ); - - expect(result.success).toBe(true); - if (result.success) { - expect(result.data.identifiers).toEqual({ - orderUid: "550e8400-e29b-41d4-a716-446655440000", - patientId: "550e8400-e29b-41d4-a716-446655440001", - supplierId: "supplier-123", - correlationId: "corr-id", - }); - expect(result.data.observation).toEqual(observation); - } - }); - }); - - describe("validateDBData", () => { - let identifiers: any; - let observation: any; - let testOrderResult: any; - - beforeEach(() => { - identifiers = { - orderUid: "order-uid", - patientId: "patient-uid", - supplierId: "supplier-123", - correlationId: "corr-id", - }; - - observation = { - interpretation: [{ coding: [{ code: "N" }] }], - }; - - testOrderResult = { - correlation_id: "corr-id", - result_status: ResultStatus.Result_Available, - patient_uid: "patient-uid", - supplier_id: "supplier-123", - }; - - jest - .spyOn(validation, "extractInterpretationCodeFromFHIRObservation") - .mockReturnValue(InterpretationCode.Normal); - }); - - it("returns not-found when testOrderResult is null", async () => { - const result = await validation.validateDBData( - identifiers, - observation, - null as any, - commons, - ); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toEqual({ - errorCode: 404, - errorType: "not-found", - errorMessage: "No order found for orderUid order-uid", - severity: "error", - }); - } - }); - - it("returns conflict when idempotency check fails (different result)", async () => { - testOrderResult.result_status = ResultStatus.Result_Withheld; // mismatch with mapping - const result = await validation.validateDBData( - identifiers, - observation, - testOrderResult, - commons, - ); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toEqual({ - errorCode: 409, - errorType: "conflict", - errorMessage: - "A different result has already been submitted for this order with the same correlation ID", - severity: "error", - }); - } - }); - - it("returns success and isIdempotent=true when idempotency check passes (same result)", async () => { - testOrderResult.result_status = ResultStatus.Result_Available; // matches mapping - const result = await validation.validateDBData( - identifiers, - observation, - testOrderResult, - commons, - ); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data).toEqual({ - isIdempotent: true, - }); - } - }); - - it("returns invalid when patient_uid does not match", async () => { - testOrderResult.correlation_id = undefined; // skip idempotency - testOrderResult.patient_uid = "other-patient"; - const result = await validation.validateDBData( - identifiers, - observation, - testOrderResult, - commons, - ); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toEqual({ - errorCode: 400, - errorType: "invalid", - errorMessage: "Patient ID in Observation does not match order record", - severity: "error", - }); - } - }); - - it("returns forbidden when supplier_id does not match", async () => { - testOrderResult.correlation_id = undefined; // skip idempotency - testOrderResult.supplier_id = "other-supplier"; - const result = await validation.validateDBData( - identifiers, - observation, - testOrderResult, - commons, - ); - expect(result.success).toBe(false); - if (!result.success) { - expect(result.error).toEqual({ - errorCode: 403, - errorType: "forbidden", - errorMessage: "Supplier not authorized for this order", - severity: "error", - }); - } - }); - - it("returns valid when all checks pass and not idempotent", async () => { - testOrderResult.correlation_id = undefined; // skip idempotency - testOrderResult.patient_uid = "patient-uid"; - testOrderResult.supplier_id = "supplier-123"; - const result = await validation.validateDBData( - identifiers, - observation, - testOrderResult, - commons, - ); - expect(result.success).toBe(true); - if (result.success) { - expect(result.data).toEqual({ isIdempotent: false }); - } - }); - }); - describe("extractOrderUidFromFHIRObservation", () => { beforeEach(() => { jest.spyOn(utils, "isUUID").mockImplementation((id: string) => { diff --git a/lambdas/src/hiv-result-processor-lambda/validation-service.ts b/lambdas/src/hiv-result-processor-lambda/validation-service.ts index a783cc56f..a8b3faa26 100644 --- a/lambdas/src/hiv-result-processor-lambda/validation-service.ts +++ b/lambdas/src/hiv-result-processor-lambda/validation-service.ts @@ -1,175 +1,7 @@ -import { APIGatewayProxyEvent } from "aws-lambda"; import { Observation } from "fhir/r4"; -import { ConsoleCommons } from "../lib/commons"; -import { OrderResultSummary } from "../lib/db/order-db"; import { getCorrelationIdFromEventHeaders, isUUID } from "../lib/utils/utils"; -import { generateReadableError } from "../lib/utils/validation-utils"; -import { - Identifiers, - InterpretationCode, - orderResultFHIRObservationSchema, - resultCodeMapping, -} from "./models"; -import { ValidationResult, ValidationResultError, errorResult, successResult } from "./validation"; - -function invalidErrorResult(errorMessage: string): ValidationResultError { - return errorResult({ - errorCode: 400, - errorType: "invalid", - errorMessage: errorMessage, - severity: "error", - }); -} - -export const validateAndExtractObservation = ( - body: string | null, - commons: ConsoleCommons, -): Observation => { - let observation: Observation; - - try { - if (body === "{}" || body === null) { - throw new Error("Body is empty"); - } - observation = JSON.parse(body); - } catch (error) { - commons.logError("order-result-lambda", "Invalid JSON in request body", { error }); - throw error; - } - - const validationResult = orderResultFHIRObservationSchema.safeParse(observation); - - if (!validationResult.success) { - const errorDetails = generateReadableError(validationResult.error); - - commons.logError("order-result-lambda", "Validation failed", { error: errorDetails }); - throw new Error(`FHIR Observation validation error: ${errorDetails}`); - } - - return observation; -}; - -export function extractAndValidateObservationFields( - event: APIGatewayProxyEvent, - commons: ConsoleCommons, -): ValidationResult<{ observation: Observation; identifiers: Identifiers }> { - let observation: Observation; - try { - observation = validateAndExtractObservation(event.body, commons); - } catch (error) { - return invalidErrorResult((error as Error).message); - } - - let correlationId: string; - - try { - correlationId = getCorrelationIdFromEventHeaders(event); - } catch (error) { - commons.logError("order-result-lambda", "Header validation failed", { - error: (error as Error).message, - }); - return invalidErrorResult((error as Error).message); - } - - let orderUid: string, patientId: string, supplierId: string; - - try { - orderUid = extractOrderUidFromFHIRObservation(observation); - patientId = extractPatientIdFromFHIRObservation(observation); - supplierId = extractSupplierIdFromFHIRObservation(observation); - } catch (error) { - commons.logError("order-result-lambda", "Error extracting identifiers from Observation", { - error, - }); - - return invalidErrorResult("Unable to extract necessary identifiers from Observation"); - } - - const identifiers: Identifiers = { - orderUid, - patientId, - supplierId, - correlationId, - }; - - return successResult({ - observation, - identifiers, - }); -} - -export async function validateDBData( - identifiers: Identifiers, - observation: Observation, - testOrderResult: OrderResultSummary, - commons: ConsoleCommons, -): Promise> { - const interpretationCode = extractInterpretationCodeFromFHIRObservation(observation); - const { orderUid, patientId, supplierId, correlationId } = identifiers; - - if (!testOrderResult) { - commons.logError("order-result-lambda", "Test order not found for orderUid", { orderUid }); - return errorResult({ - errorCode: 404, - errorType: "not-found", - errorMessage: `No order found for orderUid ${orderUid}`, - severity: "error", - }); - } - - // Idempotency check - if (testOrderResult.correlation_id && testOrderResult.correlation_id === correlationId) { - if (resultCodeMapping[interpretationCode] !== testOrderResult.result_status) { - commons.logError( - "order-result-lambda", - "Idempotency check failed, different result detected on same correlation ID.", - { orderUid, correlationId }, - ); - return errorResult({ - errorCode: 409, - errorType: "conflict", - errorMessage: - "A different result has already been submitted for this order with the same correlation ID", - severity: "error", - }); - } - - commons.logInfo( - "order-result-lambda", - "Duplicate submission with same correlation ID detected, returning success without reprocessing", - { orderUid, correlationId }, - ); - return successResult({ - isIdempotent: true, - }); - } - - if (testOrderResult.patient_uid !== patientId) { - commons.logError( - "order-result-lambda", - "Patient ID in Observation does not match test order record", - { orderUid, patientId }, - ); - return invalidErrorResult("Patient ID in Observation does not match order record"); - } - - if (testOrderResult.supplier_id !== supplierId) { - commons.logError( - "order-result-lambda", - "Supplier ID in Observation does not match test order record", - { orderUid, supplierId }, - ); - return errorResult({ - errorCode: 403, - errorType: "forbidden", - errorMessage: "Supplier not authorized for this order", - severity: "error", - }); - } - - return successResult({ isIdempotent: false }); -} +import { InterpretationCode } from "./models"; export function extractOrderUidFromFHIRObservation(observation: Observation): string { if (observation.basedOn?.length === 0) { From 819fbaca24d0c740b1163128ff54f4ab145805a2 Mon Sep 17 00:00:00 2001 From: BenKainos-43 Date: Thu, 23 Apr 2026 16:06:05 +0100 Subject: [PATCH 14/30] Refactor: Extract FHIR observation extractors to shared module --- .../validation-service.ts | 66 ++---------------- .../lib/fhir-observation-extractors/index.ts | 57 ++++++++++++++++ .../order-result-lambda/validation-service.ts | 67 ++++--------------- 3 files changed, 75 insertions(+), 115 deletions(-) create mode 100644 lambdas/src/lib/fhir-observation-extractors/index.ts diff --git a/lambdas/src/hiv-result-processor-lambda/validation-service.ts b/lambdas/src/hiv-result-processor-lambda/validation-service.ts index a8b3faa26..782ded9cf 100644 --- a/lambdas/src/hiv-result-processor-lambda/validation-service.ts +++ b/lambdas/src/hiv-result-processor-lambda/validation-service.ts @@ -1,60 +1,6 @@ -import { Observation } from "fhir/r4"; - -import { getCorrelationIdFromEventHeaders, isUUID } from "../lib/utils/utils"; -import { InterpretationCode } from "./models"; - -export function extractOrderUidFromFHIRObservation(observation: Observation): string { - if (observation.basedOn?.length === 0) { - throw new Error("Observation.basedOn is empty"); - } - - const reference = observation.basedOn?.[0]?.reference; - - if (!reference) { - throw new Error("Observation.basedOn[0].reference is missing"); - } - - // Extract UUID from reference like "ServiceRequest/550e8400-e29b-41d4-a716-446655440000" - const parts = reference.split("/"); - if (parts.length !== 2) { - throw new Error("Invalid basedOn reference format"); - } - - const orderUID = parts[1]; - - if (!isUUID(orderUID)) { - throw new Error("Invalid orderUID format"); - } - - return orderUID; -} - -export function extractPatientIdFromFHIRObservation(observation: Observation): string { - const parts = observation.subject!.reference!.split("/"); - if (parts.length !== 2) { - throw new Error("Invalid subject reference format"); - } - - const patientId = parts[1]; - - if (!isUUID(patientId)) { - throw new Error("Invalid patient ID format"); - } - - return patientId; -} - -export function extractSupplierIdFromFHIRObservation(observation: Observation): string { - const parts = observation.performer![0].reference!.split("/"); - if (parts.length !== 2) { - throw new Error("Invalid performer reference format"); - } - - return parts[1]; -} - -export function extractInterpretationCodeFromFHIRObservation( - observation: Observation, -): InterpretationCode { - return observation.interpretation![0].coding![0].code as InterpretationCode; -} +export { + extractOrderUidFromFHIRObservation, + extractPatientIdFromFHIRObservation, + extractSupplierIdFromFHIRObservation, + extractInterpretationCodeFromFHIRObservation, +} from "../lib/fhir-observation-extractors"; diff --git a/lambdas/src/lib/fhir-observation-extractors/index.ts b/lambdas/src/lib/fhir-observation-extractors/index.ts new file mode 100644 index 000000000..a07306812 --- /dev/null +++ b/lambdas/src/lib/fhir-observation-extractors/index.ts @@ -0,0 +1,57 @@ +import { Observation } from "fhir/r4"; + +import { isUUID } from "../utils/utils"; + +export function extractOrderUidFromFHIRObservation(observation: Observation): string { + if (observation.basedOn?.length === 0) { + throw new Error("Observation.basedOn is empty"); + } + + const reference = observation.basedOn?.[0]?.reference; + + if (!reference) { + throw new Error("Observation.basedOn[0].reference is missing"); + } + + // Extract UUID from reference like "ServiceRequest/550e8400-e29b-41d4-a716-446655440000" + const parts = reference.split("/"); + if (parts.length !== 2) { + throw new Error("Invalid basedOn reference format"); + } + + const orderUID = parts[1]; + + if (!isUUID(orderUID)) { + throw new Error("Invalid orderUID format"); + } + + return orderUID; +} + +export function extractPatientIdFromFHIRObservation(observation: Observation): string { + const parts = observation.subject!.reference!.split("/"); + if (parts.length !== 2) { + throw new Error("Invalid subject reference format"); + } + + const patientId = parts[1]; + + if (!isUUID(patientId)) { + throw new Error("Invalid patient ID format"); + } + + return patientId; +} + +export function extractSupplierIdFromFHIRObservation(observation: Observation): string { + const parts = observation.performer![0].reference!.split("/"); + if (parts.length !== 2) { + throw new Error("Invalid performer reference format"); + } + + return parts[1]; +} + +export function extractInterpretationCodeFromFHIRObservation(observation: Observation): string { + return observation.interpretation![0].coding![0].code as string; +} diff --git a/lambdas/src/order-result-lambda/validation-service.ts b/lambdas/src/order-result-lambda/validation-service.ts index a0ac80ac2..82b51166a 100644 --- a/lambdas/src/order-result-lambda/validation-service.ts +++ b/lambdas/src/order-result-lambda/validation-service.ts @@ -2,6 +2,12 @@ import { APIGatewayProxyEvent } from "aws-lambda"; import { Observation } from "fhir/r4"; import { OrderResultSummary } from "../lib/db/order-db"; +import { + extractInterpretationCodeFromFHIRObservation, + extractOrderUidFromFHIRObservation, + extractPatientIdFromFHIRObservation, + extractSupplierIdFromFHIRObservation, +} from "../lib/fhir-observation-extractors"; import { getCorrelationIdFromEventHeaders, isUUID } from "../lib/utils/utils"; import { generateReadableError } from "../lib/utils/validation-utils"; import { @@ -165,58 +171,9 @@ export async function validateDBData( return successResult({ isIdempotent: false }); } -export function extractOrderUidFromFHIRObservation(observation: Observation): string { - if (observation.basedOn?.length === 0) { - throw new Error("Observation.basedOn is empty"); - } - - const reference = observation.basedOn?.[0]?.reference; - - if (!reference) { - throw new Error("Observation.basedOn[0].reference is missing"); - } - - // Extract UUID from reference like "ServiceRequest/550e8400-e29b-41d4-a716-446655440000" - const parts = reference.split("/"); - if (parts.length !== 2) { - throw new Error("Invalid basedOn reference format"); - } - - const orderUID = parts[1]; - - if (!isUUID(orderUID)) { - throw new Error("Invalid orderUID format"); - } - - return orderUID; -} - -export function extractPatientIdFromFHIRObservation(observation: Observation): string { - const parts = observation.subject!.reference!.split("/"); - if (parts.length !== 2) { - throw new Error("Invalid subject reference format"); - } - - const patientId = parts[1]; - - if (!isUUID(patientId)) { - throw new Error("Invalid patient ID format"); - } - - return patientId; -} - -export function extractSupplierIdFromFHIRObservation(observation: Observation): string { - const parts = observation.performer![0].reference!.split("/"); - if (parts.length !== 2) { - throw new Error("Invalid performer reference format"); - } - - return parts[1]; -} - -export function extractInterpretationCodeFromFHIRObservation( - observation: Observation, -): InterpretationCode { - return observation.interpretation![0].coding![0].code as InterpretationCode; -} +export { + extractOrderUidFromFHIRObservation, + extractPatientIdFromFHIRObservation, + extractSupplierIdFromFHIRObservation, + extractInterpretationCodeFromFHIRObservation, +} from "../lib/fhir-observation-extractors"; From 9012007a67912cdd57cdd700b9b437f928cf98d7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Fri, 24 Apr 2026 12:49:23 +0200 Subject: [PATCH 15/30] introduced flow for new status --- .../000011_add_result_processed_status.sql | 8 + lambdas/src/lib/db/test-result-db-client.ts | 34 +- lambdas/src/lib/types/status.ts | 1 + lambdas/src/lib/utils/fhir-utils.ts | 5 + .../utils/validation-result.ts} | 5 +- .../order-result-lambda/validation-service.ts | 7 +- .../db/commands/insert-result-status.test.ts | 60 +++ .../db/commands/insert-result-status.ts | 20 + lambdas/src/order-status-lambda/index.test.ts | 373 +++++------------- lambdas/src/order-status-lambda/index.ts | 204 ++++------ lambdas/src/order-status-lambda/init.test.ts | 3 + lambdas/src/order-status-lambda/init.ts | 4 + .../order-status-lambda/models/mappings.ts | 30 ++ .../src/order-status-lambda/models/schemas.ts | 20 + .../src/order-status-lambda/models/types.ts | 13 + lambdas/src/order-status-lambda/types.ts | 11 - lambdas/src/order-status-lambda/utils.ts | 22 -- .../correlation-id-validation.test.ts | 59 +++ .../validation/correlation-id-validation.ts | 23 ++ .../validation/patient-validation.test.ts | 51 +++ .../validation/patient-validation.ts | 38 ++ .../validation/task-validation.test.ts | 133 +++++++ .../validation/task-validation.ts | 48 +++ 23 files changed, 741 insertions(+), 431 deletions(-) create mode 100644 database/migrations/000011_add_result_processed_status.sql create mode 100644 lambdas/src/lib/utils/fhir-utils.ts rename lambdas/src/{order-result-lambda/validation.ts => lib/utils/validation-result.ts} (93%) create mode 100644 lambdas/src/order-status-lambda/db/commands/insert-result-status.test.ts create mode 100644 lambdas/src/order-status-lambda/db/commands/insert-result-status.ts create mode 100644 lambdas/src/order-status-lambda/models/mappings.ts create mode 100644 lambdas/src/order-status-lambda/models/schemas.ts create mode 100644 lambdas/src/order-status-lambda/models/types.ts delete mode 100644 lambdas/src/order-status-lambda/types.ts delete mode 100644 lambdas/src/order-status-lambda/utils.ts create mode 100644 lambdas/src/order-status-lambda/validation/correlation-id-validation.test.ts create mode 100644 lambdas/src/order-status-lambda/validation/correlation-id-validation.ts create mode 100644 lambdas/src/order-status-lambda/validation/patient-validation.test.ts create mode 100644 lambdas/src/order-status-lambda/validation/patient-validation.ts create mode 100644 lambdas/src/order-status-lambda/validation/task-validation.test.ts create mode 100644 lambdas/src/order-status-lambda/validation/task-validation.ts diff --git a/database/migrations/000011_add_result_processed_status.sql b/database/migrations/000011_add_result_processed_status.sql new file mode 100644 index 000000000..92a94e383 --- /dev/null +++ b/database/migrations/000011_add_result_processed_status.sql @@ -0,0 +1,8 @@ +-- +goose Up +INSERT INTO result_type (result_code, description) +VALUES ('RESULT_PROCESSED', 'Test has been processed at the lab but results are not yet available') +ON CONFLICT DO NOTHING; + +-- +goose Down +DELETE FROM result_type +WHERE result_code = 'RESULT_PROCESSED'; diff --git a/lambdas/src/lib/db/test-result-db-client.ts b/lambdas/src/lib/db/test-result-db-client.ts index 0a5b4735b..3e549b22c 100644 --- a/lambdas/src/lib/db/test-result-db-client.ts +++ b/lambdas/src/lib/db/test-result-db-client.ts @@ -1,3 +1,4 @@ +import { ResultStatus } from "../types/status"; import { type DBClient } from "./db-client"; type TestResultStatusCode = "RESULT_AVAILABLE" | "RESULT_WITHHELD"; @@ -11,11 +12,7 @@ export interface TestResult { export class TestResultDbClient { constructor(private readonly dbClient: DBClient) {} - public async getResult( - orderId: string, - nhsNumber: string, - dateOfBirth: Date, - ) { + public async getResult(orderId: string, nhsNumber: string, dateOfBirth: Date) { const query = ` SELECT rs.result_id AS id, @@ -39,11 +36,30 @@ export class TestResultDbClient { LIMIT 1; `; - const result = await this.dbClient.query< - TestResult, - [string, string, Date] - >(query, [orderId, nhsNumber, dateOfBirth]); + const result = await this.dbClient.query(query, [ + orderId, + nhsNumber, + dateOfBirth, + ]); return result?.rows[0] ?? null; } + + public async insertResultStatus( + orderId: string, + status: ResultStatus, + correlationId: string, + ): Promise { + const query = ` + INSERT INTO result_status (order_uid, status, correlation_id) + VALUES ($1::uuid, $2, $3::uuid) + ON CONFLICT (correlation_id) DO NOTHING; + `; + + await this.dbClient.query(query, [ + orderId, + status, + correlationId, + ]); + } } diff --git a/lambdas/src/lib/types/status.ts b/lambdas/src/lib/types/status.ts index 2afd241cb..ad355e569 100644 --- a/lambdas/src/lib/types/status.ts +++ b/lambdas/src/lib/types/status.ts @@ -11,4 +11,5 @@ export enum OrderStatus { export enum ResultStatus { Result_Available = "RESULT_AVAILABLE", Result_Withheld = "RESULT_WITHHELD", + Result_Processed = "RESULT_PROCESSED", } diff --git a/lambdas/src/lib/utils/fhir-utils.ts b/lambdas/src/lib/utils/fhir-utils.ts new file mode 100644 index 000000000..53006fc4e --- /dev/null +++ b/lambdas/src/lib/utils/fhir-utils.ts @@ -0,0 +1,5 @@ +export const extractIdFromReference = (reference: string): string | null => { + const parts = reference.split("/"); + + return parts.length === 2 ? parts[1] : null; +}; diff --git a/lambdas/src/order-result-lambda/validation.ts b/lambdas/src/lib/utils/validation-result.ts similarity index 93% rename from lambdas/src/order-result-lambda/validation.ts rename to lambdas/src/lib/utils/validation-result.ts index de78b9c18..6693ee2c3 100644 --- a/lambdas/src/order-result-lambda/validation.ts +++ b/lambdas/src/lib/utils/validation-result.ts @@ -1,4 +1,4 @@ -import { ErrorStatusCode } from "../lib/fhir-response"; +import { ErrorStatusCode } from "../fhir-response"; export interface ValidationError { errorCode: ErrorStatusCode; @@ -6,12 +6,15 @@ export interface ValidationError { errorMessage: string; severity: "error" | "warning" | "information"; } + export type ValidationResult = ValidationResultSuccess | ValidationResultError; + export type ValidationResultSuccess = { success: true; data: T; error?: never; }; + export type ValidationResultError = { success: false; data?: never; diff --git a/lambdas/src/order-result-lambda/validation-service.ts b/lambdas/src/order-result-lambda/validation-service.ts index a0ac80ac2..6ecc7607c 100644 --- a/lambdas/src/order-result-lambda/validation-service.ts +++ b/lambdas/src/order-result-lambda/validation-service.ts @@ -3,6 +3,12 @@ import { Observation } from "fhir/r4"; import { OrderResultSummary } from "../lib/db/order-db"; import { getCorrelationIdFromEventHeaders, isUUID } from "../lib/utils/utils"; +import { + ValidationResult, + ValidationResultError, + errorResult, + successResult, +} from "../lib/utils/validation-result"; import { generateReadableError } from "../lib/utils/validation-utils"; import { Identifiers, @@ -10,7 +16,6 @@ import { orderResultFHIRObservationSchema, resultCodeMapping, } from "./models"; -import { ValidationResult, ValidationResultError, errorResult, successResult } from "./validation"; const name = "order-result-lambda"; diff --git a/lambdas/src/order-status-lambda/db/commands/insert-result-status.test.ts b/lambdas/src/order-status-lambda/db/commands/insert-result-status.test.ts new file mode 100644 index 000000000..7e8ba2ae0 --- /dev/null +++ b/lambdas/src/order-status-lambda/db/commands/insert-result-status.test.ts @@ -0,0 +1,60 @@ +import { type DBClient } from "../../../lib/db/db-client"; +import { ResultStatus } from "../../../lib/types/status"; +import { InsertResultStatusCommand } from "./insert-result-status"; + +const normalizeWhitespace = (sql: string): string => sql.replace(/\s+/g, " ").trim(); + +describe("InsertResultStatusCommand", () => { + let dbClient: jest.Mocked>; + let command: InsertResultStatusCommand; + + beforeEach(() => { + jest.clearAllMocks(); + + dbClient = { + query: jest.fn(), + withTransaction: jest.fn(), + close: jest.fn().mockResolvedValue(undefined), + }; + + command = new InsertResultStatusCommand(dbClient as DBClient); + }); + + const expectedQuery = ` + INSERT INTO result_status (order_uid, status, correlation_id) + VALUES ($1::uuid, $2, $3::uuid) + ON CONFLICT (correlation_id) DO NOTHING; + `; + + it("executes the correct SQL with all parameters", async () => { + dbClient.query.mockResolvedValue({ rows: [], rowCount: 1 }); + + await command.execute( + "9f44d6e9-7829-49f1-a327-8eca95f5db32", + ResultStatus.Result_Processed, + "c1a2b3c4-d5e6-7890-abcd-ef1234567890", + ); + + expect(dbClient.query).toHaveBeenCalledTimes(1); + expect(normalizeWhitespace(dbClient.query.mock.calls[0][0])).toBe( + normalizeWhitespace(expectedQuery), + ); + expect(dbClient.query.mock.calls[0][1]).toEqual([ + "9f44d6e9-7829-49f1-a327-8eca95f5db32", + ResultStatus.Result_Processed, + "c1a2b3c4-d5e6-7890-abcd-ef1234567890", + ]); + }); + + it("inserts with ON CONFLICT DO NOTHING for duplicate correlation IDs", async () => { + dbClient.query.mockResolvedValue({ rows: [], rowCount: 0 }); + + await command.execute( + "9f44d6e9-7829-49f1-a327-8eca95f5db32", + ResultStatus.Result_Processed, + "c1a2b3c4-d5e6-7890-abcd-ef1234567890", + ); + + expect(dbClient.query).toHaveBeenCalledTimes(1); + }); +}); diff --git a/lambdas/src/order-status-lambda/db/commands/insert-result-status.ts b/lambdas/src/order-status-lambda/db/commands/insert-result-status.ts new file mode 100644 index 000000000..b2609c2e8 --- /dev/null +++ b/lambdas/src/order-status-lambda/db/commands/insert-result-status.ts @@ -0,0 +1,20 @@ +import { type DBClient } from "../../../lib/db/db-client"; +import { ResultStatus } from "../../../lib/types/status"; + +export class InsertResultStatusCommand { + constructor(private readonly dbClient: DBClient) {} + + async execute(orderId: string, status: ResultStatus, correlationId: string): Promise { + const query = ` + INSERT INTO result_status (order_uid, status, correlation_id) + VALUES ($1::uuid, $2, $3::uuid) + ON CONFLICT (correlation_id) DO NOTHING; + `; + + await this.dbClient.query(query, [ + orderId, + status, + correlationId, + ]); + } +} diff --git a/lambdas/src/order-status-lambda/index.test.ts b/lambdas/src/order-status-lambda/index.test.ts index 6a1cb0fc8..29138358a 100644 --- a/lambdas/src/order-status-lambda/index.test.ts +++ b/lambdas/src/order-status-lambda/index.test.ts @@ -1,9 +1,10 @@ import { APIGatewayProxyEvent, Context } from "aws-lambda"; import { IdempotencyCheckResult } from "../lib/db/order-status-db"; -import { OrderStatusFHIRTask } from "./index"; -import { IncomingBusinessStatus } from "./types"; -import { businessStatusMapping } from "./utils"; +import { errorResult, successResult } from "../lib/utils/validation-result"; +import { orderStatusMapping } from "./models/mappings"; +import { OrderStatusFHIRTask } from "./models/schemas"; +import { IncomingBusinessStatus } from "./models/types"; const mockInit = jest.fn(); @@ -12,16 +13,26 @@ const mockCheckIdempotency = jest.fn(); const mockAddOrderStatusUpdate = jest.fn(); const mockNotify = jest.fn(); const mockHandleReminderOrderStatusUpdated = jest.fn(); +const mockInsertResultStatusCommand = jest.fn(); -const mockGetCorrelationIdFromEventHeaders = jest.fn(); +const mockValidateAndExtractCorrelationId = jest.fn(); +const mockValidateAndExtractTask = jest.fn(); +const mockValidatePatientOwnership = jest.fn(); jest.mock("./init", () => ({ init: mockInit, })); -jest.mock("../lib/utils/utils", () => ({ - ...jest.requireActual("../lib/utils/utils"), - getCorrelationIdFromEventHeaders: () => mockGetCorrelationIdFromEventHeaders(), +jest.mock("./validation/correlation-id-validation", () => ({ + validateAndExtractCorrelationId: mockValidateAndExtractCorrelationId, +})); + +jest.mock("./validation/task-validation", () => ({ + validateAndExtractTask: mockValidateAndExtractTask, +})); + +jest.mock("./validation/patient-validation", () => ({ + validatePatientOwnership: mockValidatePatientOwnership, })); const MOCK_CORRELATION_ID = "123e4567-e89b-12d3-a456-426614174000"; @@ -33,18 +44,40 @@ describe("Order Status Lambda Handler", () => { let handler: (event: APIGatewayProxyEvent, context: Context) => Promise; let mockEvent: Partial; + const validTask: OrderStatusFHIRTask = { + resourceType: "Task", + status: "in-progress", + intent: "order", + identifier: [ + { + value: MOCK_ORDER_UID, + }, + ], + for: { + reference: `Patient/${MOCK_PATIENT_UID}`, + }, + lastModified: "2024-01-15T10:00:00Z", + businessStatus: { + text: MOCK_BUSINESS_STATUS, + }, + }; + beforeEach(async () => { jest.resetModules(); jest.clearAllMocks(); mockEvent = {}; - mockGetCorrelationIdFromEventHeaders.mockReturnValue(MOCK_CORRELATION_ID); + mockValidateAndExtractCorrelationId.mockReturnValue(successResult(MOCK_CORRELATION_ID)); + mockValidateAndExtractTask.mockReturnValue(successResult(validTask)); + mockValidatePatientOwnership.mockReturnValue(successResult()); + mockGetPatientIdFromOrder.mockResolvedValue(MOCK_PATIENT_UID); mockCheckIdempotency.mockResolvedValue({ isDuplicate: false }); mockAddOrderStatusUpdate.mockResolvedValue(undefined); mockNotify.mockResolvedValue(undefined); mockHandleReminderOrderStatusUpdated.mockResolvedValue(undefined); + mockInsertResultStatusCommand.mockResolvedValue(undefined); mockInit.mockReturnValue({ orderStatusDb: { @@ -58,6 +91,9 @@ describe("Order Status Lambda Handler", () => { orderStatusReminderService: { handleOrderStatusUpdated: mockHandleReminderOrderStatusUpdated, }, + insertResultStatusCommand: { + execute: mockInsertResultStatusCommand, + }, }); const module = await import("./index"); @@ -65,91 +101,66 @@ describe("Order Status Lambda Handler", () => { handler = module.lambdaHandler; }); - const validTaskBody: OrderStatusFHIRTask = { - resourceType: "Task", - status: "in-progress", - intent: "order", - identifier: [ - { - value: MOCK_ORDER_UID, - }, - ], - for: { - reference: `Patient/${MOCK_PATIENT_UID}`, - }, - lastModified: "2024-01-15T10:00:00Z", - businessStatus: { - text: MOCK_BUSINESS_STATUS, - }, - }; - - describe("Request Parsing and Validation", () => { - it("should return 400 if request body is empty JSON object", async () => { - mockEvent.body = "{}"; + describe("Validation Delegation", () => { + it("should return FHIR error when correlation ID validation fails", async () => { + mockValidateAndExtractCorrelationId.mockReturnValueOnce( + errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Correlation ID is missing or invalid", + severity: "error", + }), + ); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(400); const body = JSON.parse(result.body); - expect(body.resourceType).toBe("OperationOutcome"); expect(body.issue[0].code).toBe("invalid"); - - expect(body.issue[0].diagnostics).toMatch(/identifier|lastModified|businessStatus/); + expect(body.issue[0].diagnostics).toMatch(/correlation id/i); }); - it("should return 400 if request body is null", async () => { - mockEvent.body = null; + it("should return FHIR error when task validation fails", async () => { + mockValidateAndExtractTask.mockReturnValueOnce( + errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Request body is required", + severity: "error", + }), + ); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(400); const body = JSON.parse(result.body); - expect(body.resourceType).toBe("OperationOutcome"); expect(body.issue[0].code).toBe("invalid"); - expect(body.issue[0].diagnostics).toMatch(/Request body is required/); - }); - - it("should return 400 if request body is invalid JSON", async () => { - mockEvent.body = "{invalid json"; - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(400); - - const body = JSON.parse(result.body); - - expect(body.resourceType).toBe("OperationOutcome"); }); - it("should return 400 if Task schema validation fails", async () => { - mockEvent.body = JSON.stringify({ - resourceType: "Task", - status: "in-progress", - for: { - reference: `Patient/${MOCK_PATIENT_UID}`, - }, - businessStatus: { - text: "invalid-business-status", - }, - } satisfies Partial>); + it("should return FHIR error when patient ownership validation fails", async () => { + mockValidatePatientOwnership.mockReturnValueOnce( + errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Patient ID does not match the order", + severity: "error", + }), + ); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(400); - const body = JSON.parse(result.body); - expect(body.resourceType).toBe("OperationOutcome"); - expect(body.issue[0].code).toBe("invalid"); + expect(body.issue[0].diagnostics).toContain("Patient ID does not match"); }); }); describe("Order Existence", () => { it("should return 404 when order does not exist", async () => { mockGetPatientIdFromOrder.mockResolvedValueOnce(null); - mockEvent.body = JSON.stringify(validTaskBody); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); @@ -164,7 +175,6 @@ describe("Order Status Lambda Handler", () => { it("should proceed when order exists", async () => { mockGetPatientIdFromOrder.mockResolvedValueOnce(MOCK_PATIENT_UID); - mockEvent.body = JSON.stringify(validTaskBody); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); @@ -173,122 +183,12 @@ describe("Order Status Lambda Handler", () => { }); }); - describe("Patient Ownership", () => { - it("should return 400 when patient reference format is invalid", async () => { - mockEvent.body = JSON.stringify({ - ...validTaskBody, - for: { reference: "invalid-ref" }, - } satisfies Partial); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(400); - - const body = JSON.parse(result.body); - - expect(body.issue[0].diagnostics).toContain("Invalid patient reference"); - }); - - it("should return 400 when patient does not match order", async () => { - mockEvent.body = JSON.stringify({ - ...validTaskBody, - for: { reference: "Patient/other-patient" }, - } satisfies Partial); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(400); - - const body = JSON.parse(result.body); - - expect(body.resourceType).toBe("OperationOutcome"); - expect(body.issue[0].diagnostics).toContain("Patient ID does not match"); - }); - - it("should proceed when patient matches order", async () => { - mockEvent.body = JSON.stringify(validTaskBody); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(201); - }); - }); - - describe("Business Status Validation", () => { - it("should return 400 for invalid business status", async () => { - mockEvent.body = JSON.stringify({ - ...validTaskBody, - businessStatus: { - text: "INVALID_STATUS" as unknown as IncomingBusinessStatus, - }, - } satisfies Partial); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(400); - - const body = JSON.parse(result.body); - - expect(body.issue[0].diagnostics).toContain("businessStatus"); - }); - - it("should return 400 for missing business status", async () => { - mockEvent.body = JSON.stringify({ - ...validTaskBody, - businessStatus: undefined, - } satisfies Partial); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(400); - - const body = JSON.parse(result.body); - - expect(body.issue[0].diagnostics).toContain("businessStatus"); - }); - - it(`should accept ${IncomingBusinessStatus.DISPATCHED} business status`, async () => { - mockEvent.body = JSON.stringify({ - ...validTaskBody, - businessStatus: { text: IncomingBusinessStatus.DISPATCHED }, - } satisfies Partial); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(201); - }); - - it(`should accept ${IncomingBusinessStatus.ORDER_ACCEPTED} business status`, async () => { - mockEvent.body = JSON.stringify({ - ...validTaskBody, - businessStatus: { text: IncomingBusinessStatus.ORDER_ACCEPTED }, - } satisfies Partial); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(201); - }); - - it(`should accept ${IncomingBusinessStatus.RECEIVED_AT_LAB} business status`, async () => { - mockEvent.body = JSON.stringify({ - ...validTaskBody, - businessStatus: { text: IncomingBusinessStatus.RECEIVED_AT_LAB }, - } satisfies Partial); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(201); - }); - }); - describe("Idempotency via Correlation ID", () => { it("should detect duplicate updates with same correlation ID", async () => { mockCheckIdempotency.mockResolvedValueOnce({ isDuplicate: true, } satisfies Partial); - mockEvent.body = JSON.stringify(validTaskBody); - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(200); @@ -297,15 +197,14 @@ describe("Order Status Lambda Handler", () => { expect(mockNotify).not.toHaveBeenCalled(); }); - it("should process new updates with different correlation ID", async () => { + it("should process new updates with a different correlation ID", async () => { const newCorrelationId = "mock-new-correlation-id-123"; - mockGetCorrelationIdFromEventHeaders.mockReturnValueOnce(newCorrelationId); + mockValidateAndExtractCorrelationId.mockReturnValueOnce(successResult(newCorrelationId)); mockCheckIdempotency.mockResolvedValueOnce({ isDuplicate: false, } satisfies IdempotencyCheckResult); - mockEvent.body = JSON.stringify(validTaskBody); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(201); @@ -316,33 +215,14 @@ describe("Order Status Lambda Handler", () => { }), ); }); - - it("should return 400 when there is no correlation id", async () => { - mockEvent.headers = {}; - - mockGetCorrelationIdFromEventHeaders.mockImplementation(() => { - throw new Error("Correlation ID is missing or invalid"); - }); - - mockEvent.body = JSON.stringify(validTaskBody); - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(400); - - const body = JSON.parse(result.body); - - expect(body.issue[0].diagnostics).toMatch(/correlation id/i); - }); }); describe("Timestamp Handling", () => { - it("should accept when lastModified timestamp is older than latest update", async () => { + it("should use lastModified timestamp when it is older than the latest update", async () => { const mockedLastModifiedTimestamp = "2024-01-15T08:00:00Z"; - - mockEvent.body = JSON.stringify({ - ...validTaskBody, - lastModified: mockedLastModifiedTimestamp, // Older than latest - } satisfies Partial); + mockValidateAndExtractTask.mockReturnValueOnce( + successResult({ ...validTask, lastModified: mockedLastModifiedTimestamp }), + ); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); @@ -355,13 +235,11 @@ describe("Order Status Lambda Handler", () => { ); }); - it("should accept when lastModified timestamp is newer than latest update", async () => { + it("should use lastModified timestamp when it is newer than the latest update", async () => { const mockedLastModifiedTimestamp = "2024-01-15T11:00:00Z"; - - mockEvent.body = JSON.stringify({ - ...validTaskBody, - lastModified: mockedLastModifiedTimestamp, // Newer than latest - } satisfies Partial); + mockValidateAndExtractTask.mockReturnValueOnce( + successResult({ ...validTask, lastModified: mockedLastModifiedTimestamp }), + ); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); @@ -373,28 +251,10 @@ describe("Order Status Lambda Handler", () => { }), ); }); - - it("should reject when lastModified is missing", async () => { - const { lastModified: _lastModified, ...bodyWithoutLastModified } = validTaskBody; - - mockEvent.body = JSON.stringify({ - ...bodyWithoutLastModified, - } satisfies Partial); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(400); - - const body = JSON.parse(result.body); - - expect(body.issue[0].diagnostics).toContain("lastModified"); - }); }); describe("Successful Update", () => { it("should return 201 OK with updated Task when all validations pass", async () => { - mockEvent.body = JSON.stringify(validTaskBody); - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(201); @@ -403,28 +263,24 @@ describe("Order Status Lambda Handler", () => { const body = JSON.parse(result.body); expect(body.resourceType).toBe("Task"); - expect(body.status).toBe(validTaskBody.status); + expect(body.status).toBe(validTask.status); expect(body.for.reference).toBe(`Patient/${MOCK_PATIENT_UID}`); }); it("should call addOrderStatusUpdate with correct parameters", async () => { - mockEvent.body = JSON.stringify(validTaskBody); - await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(mockAddOrderStatusUpdate).toHaveBeenCalledWith( expect.objectContaining({ orderId: MOCK_ORDER_UID, - statusCode: businessStatusMapping[MOCK_BUSINESS_STATUS], - createdAt: validTaskBody.lastModified, + statusCode: orderStatusMapping[MOCK_BUSINESS_STATUS], + createdAt: validTask.lastModified, correlationId: MOCK_CORRELATION_ID, }), ); }); it("should delegate post-update side effects to the notification service", async () => { - mockEvent.body = JSON.stringify(validTaskBody); - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(201); @@ -433,50 +289,48 @@ describe("Order Status Lambda Handler", () => { patientId: MOCK_PATIENT_UID, correlationId: MOCK_CORRELATION_ID, orderId: MOCK_ORDER_UID, - statusCode: businessStatusMapping[MOCK_BUSINESS_STATUS], + statusCode: orderStatusMapping[MOCK_BUSINESS_STATUS], }), ); }); it("should still delegate non-dispatched statuses to the notification service", async () => { - mockEvent.body = JSON.stringify({ - ...validTaskBody, - businessStatus: { - text: IncomingBusinessStatus.RECEIVED_AT_LAB, - }, - } satisfies Partial); + mockValidateAndExtractTask.mockReturnValueOnce( + successResult({ + ...validTask, + businessStatus: { text: IncomingBusinessStatus.RECEIVED_AT_LAB }, + }), + ); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(201); expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ - statusCode: businessStatusMapping[IncomingBusinessStatus.RECEIVED_AT_LAB], + statusCode: orderStatusMapping[IncomingBusinessStatus.RECEIVED_AT_LAB], }), ); }); it("should delegate confirmed statuses to the notification service", async () => { - mockEvent.body = JSON.stringify({ - ...validTaskBody, - businessStatus: { - text: IncomingBusinessStatus.ORDER_ACCEPTED, - }, - } satisfies Partial); + mockValidateAndExtractTask.mockReturnValueOnce( + successResult({ + ...validTask, + businessStatus: { text: IncomingBusinessStatus.ORDER_ACCEPTED }, + }), + ); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(201); expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ - statusCode: businessStatusMapping[IncomingBusinessStatus.ORDER_ACCEPTED], + statusCode: orderStatusMapping[IncomingBusinessStatus.ORDER_ACCEPTED], }), ); }); it("should delegate reminder scheduling to the reminder service", async () => { - mockEvent.body = JSON.stringify(validTaskBody); - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(201); @@ -484,15 +338,14 @@ describe("Order Status Lambda Handler", () => { expect.objectContaining({ orderId: MOCK_ORDER_UID, correlationId: MOCK_CORRELATION_ID, - statusCode: businessStatusMapping[MOCK_BUSINESS_STATUS], - triggeredAt: validTaskBody.lastModified, + statusCode: orderStatusMapping[MOCK_BUSINESS_STATUS], + triggeredAt: validTask.lastModified, }), ); }); it("should return 201 when notification service fails after a successful status update", async () => { mockNotify.mockRejectedValueOnce(new Error("Unexpected side effect error")); - mockEvent.body = JSON.stringify(validTaskBody); const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); @@ -501,25 +354,9 @@ describe("Order Status Lambda Handler", () => { }); describe("Error Handling", () => { - it("should return OperationOutcome for validation errors", async () => { - mockEvent.body = JSON.stringify({ - resourceType: "Task", - // Invalid - missing required fields - } satisfies Partial); - - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); - - expect(result.statusCode).toBe(400); - const body = JSON.parse(result.body); - expect(body.resourceType).toBe("OperationOutcome"); - expect(body.issue[0].severity).toBe("error"); - }); - it("should return 500 with OperationOutcome for database errors", async () => { mockGetPatientIdFromOrder.mockRejectedValueOnce(new Error("Database connection failed")); - mockEvent.body = JSON.stringify(validTaskBody); - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(500); @@ -534,8 +371,6 @@ describe("Order Status Lambda Handler", () => { it("should return 500 with OperationOutcome for unexpected errors", async () => { mockCheckIdempotency.mockRejectedValueOnce(new Error("Unexpected error")); - mockEvent.body = JSON.stringify(validTaskBody); - const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); expect(result.statusCode).toBe(500); diff --git a/lambdas/src/order-status-lambda/index.ts b/lambdas/src/order-status-lambda/index.ts index 414c2ae37..c67c6333b 100644 --- a/lambdas/src/order-status-lambda/index.ts +++ b/lambdas/src/order-status-lambda/index.ts @@ -3,35 +3,27 @@ import cors from "@middy/http-cors"; import httpErrorHandler from "@middy/http-error-handler"; import httpSecurityHeaders from "@middy/http-security-headers"; import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; -import z from "zod"; +import { ValidationError } from "src/lib/utils/validation-result"; import { OrderStatusUpdateParams } from "../lib/db/order-status-db"; import { createFhirErrorResponse, createFhirResponse } from "../lib/fhir-response"; import { securityHeaders } from "../lib/http/security-headers"; -import { - FHIRCodeableConceptSchema, - FHIRIdentifierSchema, - FHIRReferenceSchema, - FHIRTaskSchema, -} from "../lib/models/fhir/fhir-schemas"; -import { getCorrelationIdFromEventHeaders } from "../lib/utils/utils"; import { corsOptions } from "./cors-configuration"; import { init } from "./init"; -import { IncomingBusinessStatus } from "./types"; -import { businessStatusMapping, extractIdFromReference } from "./utils"; +import { + isIncomingOrderStatus, + isIncomingResultStatus, + orderStatusMapping, + resultStatusMapping, +} from "./models/mappings"; +import { validateAndExtractCorrelationId } from "./validation/correlation-id-validation"; +import { validatePatientOwnership } from "./validation/patient-validation"; +import { validateAndExtractTask } from "./validation/task-validation"; const name = "order-status-lambda"; -const orderStatusFHIRTaskSchema = FHIRTaskSchema.extend({ - identifier: z.array(FHIRIdentifierSchema).min(1).max(1), - for: FHIRReferenceSchema, - lastModified: z.iso.datetime(), - businessStatus: FHIRCodeableConceptSchema.extend({ - text: z.enum(IncomingBusinessStatus), - }), -}); - -export type OrderStatusFHIRTask = z.infer; +const fhirErrorFromValidation = (error: ValidationError): APIGatewayProxyResult => + createFhirErrorResponse(error.errorCode, error.errorType, error.errorMessage, error.severity); /** * Lambda handler for POST /test-order/status endpoint @@ -40,65 +32,33 @@ export type OrderStatusFHIRTask = z.infer; export const lambdaHandler = async ( event: APIGatewayProxyEvent, ): Promise => { - const { orderStatusDb, orderStatusReminderService, orderStatusNotifyService } = init(); + const { + orderStatusDb, + orderStatusReminderService, + orderStatusNotifyService, + insertResultStatusCommand, + } = init(); console.info(name, "Received order status update request", { path: event.path, method: event.httpMethod, }); - let task: unknown; - - if (!event.body) { - console.error(name, "Missing request body"); - - return createFhirErrorResponse(400, "invalid", "Request body is required", "error"); - } - try { - task = JSON.parse(event.body); - } catch (error) { - console.error(name, "Invalid JSON in request body", { error }); - - return createFhirErrorResponse(400, "invalid", "Invalid JSON in request body", "error"); - } - - const validationResult = orderStatusFHIRTaskSchema.safeParse(task); - - if (!validationResult.success) { - const errorDetails = validationResult.error.issues - .map((err) => `${err.path.join(".")}: ${err.message}`) - .join("; "); - - console.error(name, "Task validation failed", { - error: errorDetails, - }); - - return createFhirErrorResponse(400, "invalid", errorDetails, "error"); - } - - const validatedTask = validationResult.data; - const orderId = validatedTask.identifier[0].value; - - try { - let correlationId: string; - - try { - correlationId = getCorrelationIdFromEventHeaders(event); - } catch (error) { - console.error(name, "Failed to retrieve correlation ID", { error }); + const correlationIdValidationResult = validateAndExtractCorrelationId(event); + if (!correlationIdValidationResult.success) { + return fhirErrorFromValidation(correlationIdValidationResult.error); + } + const correlationId = correlationIdValidationResult.data; - return createFhirErrorResponse( - 400, - "invalid", - error instanceof Error ? error.message : "Invalid correlation ID", - "error", - ); + const taskValidationResult = validateAndExtractTask(event.body); + if (!taskValidationResult.success) { + return fhirErrorFromValidation(taskValidationResult.error); } + const task = taskValidationResult.data; + const orderId = task.identifier[0].value; - // Verify order exists and retrieve associated patient ID const orderPatientId = await orderStatusDb.getPatientIdFromOrder(orderId); - if (!orderPatientId) { console.error(name, "Order not found", { orderId }); @@ -110,79 +70,87 @@ export const lambdaHandler = async ( ); } - // Verify patient ownership - const patientIdFromTask = extractIdFromReference(validatedTask.for.reference); - - if (!patientIdFromTask) { - console.error(name, "Invalid patient reference format", { - reference: validatedTask.for.reference, - }); - - return createFhirErrorResponse(400, "invalid", "Invalid patient reference format", "error"); - } - - if (patientIdFromTask !== orderPatientId) { - console.error(name, "Patient mismatch for order", { - orderId, - expectedPatient: orderPatientId, - providedPatient: patientIdFromTask, - }); + const patientOwnershipValidationResult = validatePatientOwnership( + task.for.reference, + orderPatientId, + orderId, + ); - return createFhirErrorResponse( - 400, - "invalid", - "Patient ID does not match the order", - "error", - ); + if (!patientOwnershipValidationResult.success) { + return fhirErrorFromValidation(patientOwnershipValidationResult.error); } - // Check for idempotency via Correlation ID const idempotencyCheck = await orderStatusDb.checkIdempotency(orderId, correlationId); - if (idempotencyCheck.isDuplicate) { console.info(name, "Duplicate update detected via correlation ID", { orderId, correlationId, }); - return createFhirResponse(200, validatedTask); + return createFhirResponse(200, task); } - // Process the update - const statusOrderUpdateParams: OrderStatusUpdateParams = { - orderId, - statusCode: businessStatusMapping[validatedTask.businessStatus.text], - createdAt: validatedTask.lastModified, - correlationId, - }; + const incomingStatus = task.businessStatus.text; - await orderStatusDb.addOrderStatusUpdate(statusOrderUpdateParams); + if (isIncomingResultStatus(incomingStatus)) { + const resultStatus = resultStatusMapping[incomingStatus]; - console.info(name, "Order status update added successfully", statusOrderUpdateParams); + try { + await insertResultStatusCommand.execute(orderId, resultStatus, correlationId); + } catch (error) { + console.warn(name, "Failed to update result status", { + orderId, + correlationId, + resultStatus, + }); + } - try { - await orderStatusNotifyService.dispatch({ + console.info(name, "Result status update added successfully", { orderId, - patientId: orderPatientId, correlationId, - statusCode: statusOrderUpdateParams.statusCode, + resultStatus, }); - } catch (error) { - console.error(name, "Failed to dispatch order status notification", { + + return createFhirResponse(201, task); + } + + if (isIncomingOrderStatus(incomingStatus)) { + const statusOrderUpdateParams: OrderStatusUpdateParams = { + orderId, + statusCode: orderStatusMapping[incomingStatus], + createdAt: task.lastModified, correlationId, + }; + + await orderStatusDb.addOrderStatusUpdate(statusOrderUpdateParams); + console.info(name, "Order status update added successfully", statusOrderUpdateParams); + + try { + await orderStatusNotifyService.dispatch({ + orderId, + patientId: orderPatientId, + correlationId, + statusCode: statusOrderUpdateParams.statusCode, + }); + } catch (error) { + console.error(name, "Failed to dispatch order status notification", { + correlationId, + orderId, + error, + }); + } + + await orderStatusReminderService.handleOrderStatusUpdated({ orderId, - error, + correlationId, + statusCode: statusOrderUpdateParams.statusCode, + triggeredAt: statusOrderUpdateParams.createdAt, }); - } - await orderStatusReminderService.handleOrderStatusUpdated({ - orderId, - correlationId, - statusCode: statusOrderUpdateParams.statusCode, - triggeredAt: statusOrderUpdateParams.createdAt, - }); + return createFhirResponse(201, task); + } - return createFhirResponse(201, validatedTask); + return createFhirErrorResponse(400, "invalid", "Unrecognised business status", "error"); } catch (error) { console.error(name, "Error processing order status update", { error, diff --git a/lambdas/src/order-status-lambda/init.test.ts b/lambdas/src/order-status-lambda/init.test.ts index 7d145314f..1a341a634 100644 --- a/lambdas/src/order-status-lambda/init.test.ts +++ b/lambdas/src/order-status-lambda/init.test.ts @@ -13,6 +13,7 @@ import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; import { AWSSQSClient } from "../lib/sqs/sqs-client"; import { testComponentCreationOrder } from "../lib/test-utils/component-integration-helpers"; import { restoreEnvironment, setupEnvironment } from "../lib/test-utils/environment-test-helpers"; +import { InsertResultStatusCommand } from "./db/commands/insert-result-status"; import { buildEnvironment as init } from "./init"; jest.mock("../lib/db/order-status-db"); @@ -26,6 +27,7 @@ jest.mock("../lib/db/db-config"); jest.mock("../lib/notify/services/order-status-notify-service"); jest.mock("../lib/notify/message-builders/order-status/order-confirmed-message-builder"); jest.mock("../lib/reminder/order-status-reminder-service"); +jest.mock("./db/commands/insert-result-status"); describe("init", () => { const originalEnv = process.env; @@ -106,6 +108,7 @@ describe("init", () => { orderStatusDb: expect.any(OrderStatusService), orderStatusReminderService: expect.any(OrderStatusReminderService), orderStatusNotifyService: expect.any(OrderStatusNotifyService), + insertResultStatusCommand: expect.any(InsertResultStatusCommand), }); }); }); diff --git a/lambdas/src/order-status-lambda/init.ts b/lambdas/src/order-status-lambda/init.ts index d2855300c..351a4c9be 100644 --- a/lambdas/src/order-status-lambda/init.ts +++ b/lambdas/src/order-status-lambda/init.ts @@ -13,11 +13,13 @@ import { OrderStatusReminderService } from "../lib/reminder/order-status-reminde import { AwsSecretsClient } from "../lib/secrets/secrets-manager-client"; import { AWSSQSClient } from "../lib/sqs/sqs-client"; import { retrieveMandatoryEnvVariable } from "../lib/utils/utils"; +import { InsertResultStatusCommand } from "./db/commands/insert-result-status"; export interface Environment { orderStatusDb: OrderStatusService; orderStatusReminderService: OrderStatusReminderService; orderStatusNotifyService: OrderStatusNotifyService; + insertResultStatusCommand: InsertResultStatusCommand; } export function buildEnvironment(): Environment { @@ -46,11 +48,13 @@ export function buildEnvironment(): Environment { sqsClient, notifyMessagesQueueUrl, }); + const insertResultStatusCommand = new InsertResultStatusCommand(dbClient); return { orderStatusDb, orderStatusReminderService, orderStatusNotifyService, + insertResultStatusCommand, }; } diff --git a/lambdas/src/order-status-lambda/models/mappings.ts b/lambdas/src/order-status-lambda/models/mappings.ts new file mode 100644 index 000000000..576fbeeba --- /dev/null +++ b/lambdas/src/order-status-lambda/models/mappings.ts @@ -0,0 +1,30 @@ +import { OrderStatus, ResultStatus } from "../../lib/types/status"; +import { + IncomingBusinessStatus, + IncomingOrderBusinessStatus, + IncomingResultBusinessStatus, +} from "./types"; + +export const orderStatusMapping: Record = { + [IncomingBusinessStatus.ORDER_ACCEPTED]: OrderStatus.Confirmed, + [IncomingBusinessStatus.DISPATCHED]: OrderStatus.Dispatched, + [IncomingBusinessStatus.RECEIVED_AT_LAB]: OrderStatus.Received, +}; + +export const resultStatusMapping: Record = { + [IncomingBusinessStatus.TEST_PROCESSED]: ResultStatus.Result_Processed, +}; + +const orderBusinessStatuses: readonly IncomingBusinessStatus[] = [ + IncomingBusinessStatus.ORDER_ACCEPTED, + IncomingBusinessStatus.DISPATCHED, + IncomingBusinessStatus.RECEIVED_AT_LAB, +]; + +export const isIncomingOrderStatus = ( + status: IncomingBusinessStatus, +): status is IncomingOrderBusinessStatus => orderBusinessStatuses.includes(status); + +export const isIncomingResultStatus = ( + status: IncomingBusinessStatus, +): status is IncomingResultBusinessStatus => status === IncomingBusinessStatus.TEST_PROCESSED; diff --git a/lambdas/src/order-status-lambda/models/schemas.ts b/lambdas/src/order-status-lambda/models/schemas.ts new file mode 100644 index 000000000..9c279cd58 --- /dev/null +++ b/lambdas/src/order-status-lambda/models/schemas.ts @@ -0,0 +1,20 @@ +import z from "zod"; + +import { + FHIRCodeableConceptSchema, + FHIRIdentifierSchema, + FHIRReferenceSchema, + FHIRTaskSchema, +} from "../../lib/models/fhir/fhir-schemas"; +import { IncomingBusinessStatus } from "./types"; + +export const orderStatusFHIRTaskSchema = FHIRTaskSchema.extend({ + identifier: z.array(FHIRIdentifierSchema).min(1).max(1), + for: FHIRReferenceSchema, + lastModified: z.iso.datetime(), + businessStatus: FHIRCodeableConceptSchema.extend({ + text: z.enum(IncomingBusinessStatus), + }), +}); + +export type OrderStatusFHIRTask = z.infer; diff --git a/lambdas/src/order-status-lambda/models/types.ts b/lambdas/src/order-status-lambda/models/types.ts new file mode 100644 index 000000000..94d3be725 --- /dev/null +++ b/lambdas/src/order-status-lambda/models/types.ts @@ -0,0 +1,13 @@ +export enum IncomingBusinessStatus { + ORDER_ACCEPTED = "order-accepted", + DISPATCHED = "dispatched", + RECEIVED_AT_LAB = "received-at-lab", + TEST_PROCESSED = "test-processed", +} + +export type IncomingOrderBusinessStatus = + | IncomingBusinessStatus.ORDER_ACCEPTED + | IncomingBusinessStatus.DISPATCHED + | IncomingBusinessStatus.RECEIVED_AT_LAB; + +export type IncomingResultBusinessStatus = IncomingBusinessStatus.TEST_PROCESSED; diff --git a/lambdas/src/order-status-lambda/types.ts b/lambdas/src/order-status-lambda/types.ts deleted file mode 100644 index d43d366bd..000000000 --- a/lambdas/src/order-status-lambda/types.ts +++ /dev/null @@ -1,11 +0,0 @@ -export enum IncomingBusinessStatus { - ORDER_ACCEPTED = "order-accepted", - DISPATCHED = "dispatched", - RECEIVED_AT_LAB = "received-at-lab", -} - -export enum AllowedInternalBusinessStatuses { - CONFIRMED = "CONFIRMED", - DISPATCHED = "DISPATCHED", - RECEIVED = "RECEIVED", -} diff --git a/lambdas/src/order-status-lambda/utils.ts b/lambdas/src/order-status-lambda/utils.ts deleted file mode 100644 index a1ff167d6..000000000 --- a/lambdas/src/order-status-lambda/utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -import { AllowedInternalBusinessStatuses, IncomingBusinessStatus } from "./types"; - -/** - * Extract UUID from a FHIR reference (e.g., "ServiceRequest/550e8400-e29b-41d4-a716-446655440000") - */ -export const extractIdFromReference = (reference: string): string | null => { - const parts = reference.split("/"); - - return parts.length === 2 ? parts[1] : null; -}; - -/** - * Mapping of incoming business status values to allowed internal business statuses - */ -export const businessStatusMapping: Record< - IncomingBusinessStatus, - AllowedInternalBusinessStatuses -> = { - [IncomingBusinessStatus.ORDER_ACCEPTED]: AllowedInternalBusinessStatuses.CONFIRMED, - [IncomingBusinessStatus.DISPATCHED]: AllowedInternalBusinessStatuses.DISPATCHED, - [IncomingBusinessStatus.RECEIVED_AT_LAB]: AllowedInternalBusinessStatuses.RECEIVED, -}; diff --git a/lambdas/src/order-status-lambda/validation/correlation-id-validation.test.ts b/lambdas/src/order-status-lambda/validation/correlation-id-validation.test.ts new file mode 100644 index 000000000..5fa3484b8 --- /dev/null +++ b/lambdas/src/order-status-lambda/validation/correlation-id-validation.test.ts @@ -0,0 +1,59 @@ +import { APIGatewayProxyEvent } from "aws-lambda"; + +import { validateAndExtractCorrelationId } from "./correlation-id-validation"; + +const mockGetCorrelationIdFromEventHeaders = jest.fn(); + +jest.mock("../../lib/utils/utils", () => ({ + ...jest.requireActual("../../lib/utils/utils"), + getCorrelationIdFromEventHeaders: () => mockGetCorrelationIdFromEventHeaders(), +})); + +const MOCK_CORRELATION_ID = "123e4567-e89b-12d3-a456-426614174000"; + +describe("validateAndExtractCorrelationId", () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it("should return success with the correlation ID when the header is present and valid", () => { + mockGetCorrelationIdFromEventHeaders.mockReturnValue(MOCK_CORRELATION_ID); + + const result = validateAndExtractCorrelationId({} as APIGatewayProxyEvent); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data).toBe(MOCK_CORRELATION_ID); + } + }); + + it("should return error with the thrown error message when the correlation ID header is missing or invalid", () => { + const errorMessage = "Correlation ID is missing or invalid"; + mockGetCorrelationIdFromEventHeaders.mockImplementation(() => { + throw new Error(errorMessage); + }); + + const result = validateAndExtractCorrelationId({} as APIGatewayProxyEvent); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorCode).toBe(400); + expect(result.error.errorType).toBe("invalid"); + expect(result.error.errorMessage).toBe(errorMessage); + } + }); + + it("should return a generic error message when a non-Error value is thrown", () => { + mockGetCorrelationIdFromEventHeaders.mockImplementation(() => { + throw "unexpected string error"; + }); + + const result = validateAndExtractCorrelationId({} as APIGatewayProxyEvent); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorCode).toBe(400); + expect(result.error.errorMessage).toBe("Invalid correlation ID"); + } + }); +}); diff --git a/lambdas/src/order-status-lambda/validation/correlation-id-validation.ts b/lambdas/src/order-status-lambda/validation/correlation-id-validation.ts new file mode 100644 index 000000000..2e5c668ce --- /dev/null +++ b/lambdas/src/order-status-lambda/validation/correlation-id-validation.ts @@ -0,0 +1,23 @@ +import { APIGatewayProxyEvent } from "aws-lambda"; + +import { getCorrelationIdFromEventHeaders } from "../../lib/utils/utils"; +import { ValidationResult, errorResult, successResult } from "../../lib/utils/validation-result"; + +const name = "order-status-lambda"; + +export const validateAndExtractCorrelationId = ( + event: APIGatewayProxyEvent, +): ValidationResult => { + try { + const correlationId = getCorrelationIdFromEventHeaders(event); + return successResult(correlationId); + } catch (error) { + console.error(name, "Failed to retrieve correlation ID", { error }); + return errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: error instanceof Error ? error.message : "Invalid correlation ID", + severity: "error", + }); + } +}; diff --git a/lambdas/src/order-status-lambda/validation/patient-validation.test.ts b/lambdas/src/order-status-lambda/validation/patient-validation.test.ts new file mode 100644 index 000000000..bf6753847 --- /dev/null +++ b/lambdas/src/order-status-lambda/validation/patient-validation.test.ts @@ -0,0 +1,51 @@ +import { validatePatientOwnership } from "./patient-validation"; + +const MOCK_ORDER_ID = "550e8400-e29b-41d4-a716-446655440000"; +const MOCK_PATIENT_ID = "patient-123"; + +describe("validatePatientOwnership", () => { + it("should return success when the patient reference matches the order's patient", () => { + const result = validatePatientOwnership( + `Patient/${MOCK_PATIENT_ID}`, + MOCK_PATIENT_ID, + MOCK_ORDER_ID, + ); + + expect(result.success).toBe(true); + }); + + it("should return error when the reference format is invalid (no slash)", () => { + const result = validatePatientOwnership("invalid-reference", MOCK_PATIENT_ID, MOCK_ORDER_ID); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorCode).toBe(400); + expect(result.error.errorType).toBe("invalid"); + expect(result.error.errorMessage).toContain("Invalid patient reference"); + } + }); + + it("should return error when the reference has more than two path segments", () => { + const result = validatePatientOwnership("Patient/123/extra", MOCK_PATIENT_ID, MOCK_ORDER_ID); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorMessage).toContain("Invalid patient reference"); + } + }); + + it("should return error when the patient ID in the reference does not match the order", () => { + const result = validatePatientOwnership( + "Patient/different-patient", + MOCK_PATIENT_ID, + MOCK_ORDER_ID, + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorCode).toBe(400); + expect(result.error.errorType).toBe("invalid"); + expect(result.error.errorMessage).toContain("Patient ID does not match"); + } + }); +}); diff --git a/lambdas/src/order-status-lambda/validation/patient-validation.ts b/lambdas/src/order-status-lambda/validation/patient-validation.ts new file mode 100644 index 000000000..31d441a1e --- /dev/null +++ b/lambdas/src/order-status-lambda/validation/patient-validation.ts @@ -0,0 +1,38 @@ +import { extractIdFromReference } from "../../lib/utils/fhir-utils"; +import { ValidationResult, errorResult, successResult } from "../../lib/utils/validation-result"; + +const name = "order-status-lambda"; + +export const validatePatientOwnership = ( + reference: string, + orderPatientId: string, + orderId: string, +): ValidationResult => { + const patientIdFromTask = extractIdFromReference(reference); + + if (!patientIdFromTask) { + console.error(name, "Invalid patient reference format", { reference }); + return errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Invalid patient reference format", + severity: "error", + }); + } + + if (patientIdFromTask !== orderPatientId) { + console.error(name, "Patient mismatch for order", { + orderId, + expectedPatient: orderPatientId, + providedPatient: patientIdFromTask, + }); + return errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Patient ID does not match the order", + severity: "error", + }); + } + + return successResult(); +}; diff --git a/lambdas/src/order-status-lambda/validation/task-validation.test.ts b/lambdas/src/order-status-lambda/validation/task-validation.test.ts new file mode 100644 index 000000000..602597743 --- /dev/null +++ b/lambdas/src/order-status-lambda/validation/task-validation.test.ts @@ -0,0 +1,133 @@ +import { IncomingBusinessStatus } from "../models/types"; +import { validateAndExtractTask } from "./task-validation"; + +const MOCK_ORDER_UID = "550e8400-e29b-41d4-a716-446655440000"; +const MOCK_PATIENT_UID = "patient-123"; + +const validBody = { + resourceType: "Task", + status: "in-progress", + intent: "order", + identifier: [{ value: MOCK_ORDER_UID }], + for: { reference: `Patient/${MOCK_PATIENT_UID}` }, + lastModified: "2024-01-15T10:00:00Z", + businessStatus: { text: IncomingBusinessStatus.DISPATCHED }, +}; + +describe("validateAndExtractTask", () => { + describe("Body presence", () => { + it("should return error when body is null", () => { + const result = validateAndExtractTask(null); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorCode).toBe(400); + expect(result.error.errorType).toBe("invalid"); + expect(result.error.errorMessage).toMatch(/Request body is required/); + } + }); + + it("should return error when body is an empty string", () => { + const result = validateAndExtractTask(""); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorMessage).toMatch(/Request body is required/); + } + }); + }); + + describe("JSON parsing", () => { + it("should return error when body is invalid JSON", () => { + const result = validateAndExtractTask("{invalid json"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorCode).toBe(400); + expect(result.error.errorMessage).toMatch(/Invalid JSON/); + } + }); + }); + + describe("Schema validation", () => { + it("should return error for an empty JSON object", () => { + const result = validateAndExtractTask("{}"); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorCode).toBe(400); + expect(result.error.errorType).toBe("invalid"); + expect(result.error.errorMessage).toMatch(/identifier|lastModified|businessStatus/); + } + }); + + it("should return error when identifier is missing", () => { + const { identifier: _identifier, ...bodyWithoutIdentifier } = validBody; + + const result = validateAndExtractTask(JSON.stringify(bodyWithoutIdentifier)); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorMessage).toMatch(/identifier/); + } + }); + + it("should return error when lastModified is missing", () => { + const { lastModified: _lastModified, ...bodyWithoutLastModified } = validBody; + + const result = validateAndExtractTask(JSON.stringify(bodyWithoutLastModified)); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorMessage).toMatch(/lastModified/); + } + }); + + it("should return error when businessStatus is missing", () => { + const { businessStatus: _businessStatus, ...bodyWithoutBusinessStatus } = validBody; + + const result = validateAndExtractTask(JSON.stringify(bodyWithoutBusinessStatus)); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorMessage).toMatch(/businessStatus/); + } + }); + + it("should return error when businessStatus.text is an unrecognised value", () => { + const result = validateAndExtractTask( + JSON.stringify({ ...validBody, businessStatus: { text: "INVALID_STATUS" } }), + ); + + expect(result.success).toBe(false); + if (!result.success) { + expect(result.error.errorMessage).toMatch(/businessStatus/); + } + }); + + it.each(Object.values(IncomingBusinessStatus))( + "should return success for valid businessStatus '%s'", + (status) => { + const result = validateAndExtractTask( + JSON.stringify({ ...validBody, businessStatus: { text: status } }), + ); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.businessStatus.text).toBe(status); + } + }, + ); + + it("should return success with parsed task for a fully valid body", () => { + const result = validateAndExtractTask(JSON.stringify(validBody)); + + expect(result.success).toBe(true); + if (result.success) { + expect(result.data.identifier[0].value).toBe(MOCK_ORDER_UID); + expect(result.data.for.reference).toBe(`Patient/${MOCK_PATIENT_UID}`); + expect(result.data.businessStatus.text).toBe(IncomingBusinessStatus.DISPATCHED); + } + }); + }); +}); diff --git a/lambdas/src/order-status-lambda/validation/task-validation.ts b/lambdas/src/order-status-lambda/validation/task-validation.ts new file mode 100644 index 000000000..451543023 --- /dev/null +++ b/lambdas/src/order-status-lambda/validation/task-validation.ts @@ -0,0 +1,48 @@ +import { ValidationResult, errorResult, successResult } from "../../lib/utils/validation-result"; +import { generateReadableError } from "../../lib/utils/validation-utils"; +import { OrderStatusFHIRTask, orderStatusFHIRTaskSchema } from "../models/schemas"; + +const name = "order-status-lambda"; + +export const validateAndExtractTask = ( + body: string | null, +): ValidationResult => { + if (!body) { + console.error(name, "Missing request body"); + return errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Request body is required", + severity: "error", + }); + } + + let task: unknown; + + try { + task = JSON.parse(body); + } catch (error) { + console.error(name, "Invalid JSON in request body", { error }); + return errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Invalid JSON in request body", + severity: "error", + }); + } + + const validationResult = orderStatusFHIRTaskSchema.safeParse(task); + + if (!validationResult.success) { + const errorDetails = generateReadableError(validationResult.error); + console.error(name, "Task validation failed", { error: errorDetails }); + return errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: errorDetails, + severity: "error", + }); + } + + return successResult(validationResult.data); +}; From b386eaed8ffd3316e7f326c72f6c8faa3bcf4676 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Fri, 24 Apr 2026 13:28:35 +0200 Subject: [PATCH 16/30] refactoring --- lambdas/src/order-status-lambda/index.test.ts | 12 +- lambdas/src/order-status-lambda/index.ts | 118 ++++++++---------- .../order-status-lambda/models/mappings.ts | 48 ++++--- .../src/order-status-lambda/models/types.ts | 12 +- 4 files changed, 89 insertions(+), 101 deletions(-) diff --git a/lambdas/src/order-status-lambda/index.test.ts b/lambdas/src/order-status-lambda/index.test.ts index 29138358a..213104422 100644 --- a/lambdas/src/order-status-lambda/index.test.ts +++ b/lambdas/src/order-status-lambda/index.test.ts @@ -1,8 +1,8 @@ import { APIGatewayProxyEvent, Context } from "aws-lambda"; import { IdempotencyCheckResult } from "../lib/db/order-status-db"; +import { OrderStatus } from "../lib/types/status"; import { errorResult, successResult } from "../lib/utils/validation-result"; -import { orderStatusMapping } from "./models/mappings"; import { OrderStatusFHIRTask } from "./models/schemas"; import { IncomingBusinessStatus } from "./models/types"; @@ -273,7 +273,7 @@ describe("Order Status Lambda Handler", () => { expect(mockAddOrderStatusUpdate).toHaveBeenCalledWith( expect.objectContaining({ orderId: MOCK_ORDER_UID, - statusCode: orderStatusMapping[MOCK_BUSINESS_STATUS], + statusCode: OrderStatus.Dispatched, createdAt: validTask.lastModified, correlationId: MOCK_CORRELATION_ID, }), @@ -289,7 +289,7 @@ describe("Order Status Lambda Handler", () => { patientId: MOCK_PATIENT_UID, correlationId: MOCK_CORRELATION_ID, orderId: MOCK_ORDER_UID, - statusCode: orderStatusMapping[MOCK_BUSINESS_STATUS], + statusCode: OrderStatus.Dispatched, }), ); }); @@ -307,7 +307,7 @@ describe("Order Status Lambda Handler", () => { expect(result.statusCode).toBe(201); expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ - statusCode: orderStatusMapping[IncomingBusinessStatus.RECEIVED_AT_LAB], + statusCode: OrderStatus.Received, }), ); }); @@ -325,7 +325,7 @@ describe("Order Status Lambda Handler", () => { expect(result.statusCode).toBe(201); expect(mockNotify).toHaveBeenCalledWith( expect.objectContaining({ - statusCode: orderStatusMapping[IncomingBusinessStatus.ORDER_ACCEPTED], + statusCode: OrderStatus.Confirmed, }), ); }); @@ -338,7 +338,7 @@ describe("Order Status Lambda Handler", () => { expect.objectContaining({ orderId: MOCK_ORDER_UID, correlationId: MOCK_CORRELATION_ID, - statusCode: orderStatusMapping[MOCK_BUSINESS_STATUS], + statusCode: OrderStatus.Dispatched, triggeredAt: validTask.lastModified, }), ); diff --git a/lambdas/src/order-status-lambda/index.ts b/lambdas/src/order-status-lambda/index.ts index c67c6333b..e02ac741c 100644 --- a/lambdas/src/order-status-lambda/index.ts +++ b/lambdas/src/order-status-lambda/index.ts @@ -10,12 +10,8 @@ import { createFhirErrorResponse, createFhirResponse } from "../lib/fhir-respons import { securityHeaders } from "../lib/http/security-headers"; import { corsOptions } from "./cors-configuration"; import { init } from "./init"; -import { - isIncomingOrderStatus, - isIncomingResultStatus, - orderStatusMapping, - resultStatusMapping, -} from "./models/mappings"; +import { resolveStatus } from "./models/mappings"; +import { StatusKind } from "./models/types"; import { validateAndExtractCorrelationId } from "./validation/correlation-id-validation"; import { validatePatientOwnership } from "./validation/patient-validation"; import { validateAndExtractTask } from "./validation/task-validation"; @@ -27,7 +23,8 @@ const fhirErrorFromValidation = (error: ValidationError): APIGatewayProxyResult /** * Lambda handler for POST /test-order/status endpoint - * Adds a status record for a given order based on the incoming FHIR Task resource + * Validates and processes an incoming FHIR Task resource, updating either the order status or + * result status, dispatching notifications, and managing reminders accordingly. */ export const lambdaHandler = async ( event: APIGatewayProxyEvent, @@ -57,6 +54,7 @@ export const lambdaHandler = async ( } const task = taskValidationResult.data; const orderId = task.identifier[0].value; + const logContext = { orderId, correlationId }; const orderPatientId = await orderStatusDb.getPatientIdFromOrder(orderId); if (!orderPatientId) { @@ -82,79 +80,73 @@ export const lambdaHandler = async ( const idempotencyCheck = await orderStatusDb.checkIdempotency(orderId, correlationId); if (idempotencyCheck.isDuplicate) { - console.info(name, "Duplicate update detected via correlation ID", { - orderId, - correlationId, - }); + console.info(name, "Duplicate update detected via correlation ID", logContext); return createFhirResponse(200, task); } const incomingStatus = task.businessStatus.text; + const resolved = resolveStatus(incomingStatus); + + switch (resolved.kind) { + case StatusKind.Result: { + try { + await insertResultStatusCommand.execute(orderId, resolved.status, correlationId); + } catch (error) { + console.warn(name, "Failed to update result status", { + ...logContext, + resultStatus: resolved.status, + }); + } + + console.info(name, "Result status update added successfully", { + ...logContext, + resultStatus: resolved.status, + }); - if (isIncomingResultStatus(incomingStatus)) { - const resultStatus = resultStatusMapping[incomingStatus]; + return createFhirResponse(201, task); + } - try { - await insertResultStatusCommand.execute(orderId, resultStatus, correlationId); - } catch (error) { - console.warn(name, "Failed to update result status", { + case StatusKind.Order: { + const statusOrderUpdateParams: OrderStatusUpdateParams = { orderId, + statusCode: resolved.status, + createdAt: task.lastModified, correlationId, - resultStatus, - }); - } - - console.info(name, "Result status update added successfully", { - orderId, - correlationId, - resultStatus, - }); - - return createFhirResponse(201, task); - } - - if (isIncomingOrderStatus(incomingStatus)) { - const statusOrderUpdateParams: OrderStatusUpdateParams = { - orderId, - statusCode: orderStatusMapping[incomingStatus], - createdAt: task.lastModified, - correlationId, - }; - - await orderStatusDb.addOrderStatusUpdate(statusOrderUpdateParams); - console.info(name, "Order status update added successfully", statusOrderUpdateParams); - - try { - await orderStatusNotifyService.dispatch({ + }; + + await orderStatusDb.addOrderStatusUpdate(statusOrderUpdateParams); + console.info(name, "Order status update added successfully", statusOrderUpdateParams); + + try { + await orderStatusNotifyService.dispatch({ + orderId, + patientId: orderPatientId, + correlationId, + statusCode: statusOrderUpdateParams.statusCode, + }); + } catch (error) { + console.error(name, "Failed to dispatch order status notification", { + ...logContext, + error, + }); + } + + await orderStatusReminderService.handleOrderStatusUpdated({ orderId, - patientId: orderPatientId, correlationId, statusCode: statusOrderUpdateParams.statusCode, + triggeredAt: statusOrderUpdateParams.createdAt, }); - } catch (error) { - console.error(name, "Failed to dispatch order status notification", { - correlationId, - orderId, - error, - }); - } - await orderStatusReminderService.handleOrderStatusUpdated({ - orderId, - correlationId, - statusCode: statusOrderUpdateParams.statusCode, - triggeredAt: statusOrderUpdateParams.createdAt, - }); + return createFhirResponse(201, task); + } - return createFhirResponse(201, task); + default: + return createFhirErrorResponse(400, "invalid", "Unrecognised business status", "error"); } - - return createFhirErrorResponse(400, "invalid", "Unrecognised business status", "error"); } catch (error) { - console.error(name, "Error processing order status update", { - error, - }); + console.error(name, "Error processing order status update", { error }); return createFhirErrorResponse(500, "exception", "An internal error occurred", "fatal"); } }; diff --git a/lambdas/src/order-status-lambda/models/mappings.ts b/lambdas/src/order-status-lambda/models/mappings.ts index 576fbeeba..50cc63fed 100644 --- a/lambdas/src/order-status-lambda/models/mappings.ts +++ b/lambdas/src/order-status-lambda/models/mappings.ts @@ -1,30 +1,28 @@ import { OrderStatus, ResultStatus } from "../../lib/types/status"; -import { - IncomingBusinessStatus, - IncomingOrderBusinessStatus, - IncomingResultBusinessStatus, -} from "./types"; +import { IncomingBusinessStatus, StatusKind } from "./types"; -export const orderStatusMapping: Record = { - [IncomingBusinessStatus.ORDER_ACCEPTED]: OrderStatus.Confirmed, - [IncomingBusinessStatus.DISPATCHED]: OrderStatus.Dispatched, - [IncomingBusinessStatus.RECEIVED_AT_LAB]: OrderStatus.Received, -}; +export type ResolvedStatus = + | { kind: StatusKind.Order; status: OrderStatus } + | { kind: StatusKind.Result; status: ResultStatus }; -export const resultStatusMapping: Record = { - [IncomingBusinessStatus.TEST_PROCESSED]: ResultStatus.Result_Processed, +const statusResolutionMap: Record = { + [IncomingBusinessStatus.ORDER_ACCEPTED]: { + kind: StatusKind.Order, + status: OrderStatus.Confirmed, + }, + [IncomingBusinessStatus.DISPATCHED]: { + kind: StatusKind.Order, + status: OrderStatus.Dispatched, + }, + [IncomingBusinessStatus.RECEIVED_AT_LAB]: { + kind: StatusKind.Order, + status: OrderStatus.Received, + }, + [IncomingBusinessStatus.TEST_PROCESSED]: { + kind: StatusKind.Result, + status: ResultStatus.Result_Processed, + }, }; -const orderBusinessStatuses: readonly IncomingBusinessStatus[] = [ - IncomingBusinessStatus.ORDER_ACCEPTED, - IncomingBusinessStatus.DISPATCHED, - IncomingBusinessStatus.RECEIVED_AT_LAB, -]; - -export const isIncomingOrderStatus = ( - status: IncomingBusinessStatus, -): status is IncomingOrderBusinessStatus => orderBusinessStatuses.includes(status); - -export const isIncomingResultStatus = ( - status: IncomingBusinessStatus, -): status is IncomingResultBusinessStatus => status === IncomingBusinessStatus.TEST_PROCESSED; +export const resolveStatus = (incoming: IncomingBusinessStatus): ResolvedStatus => + statusResolutionMap[incoming]; diff --git a/lambdas/src/order-status-lambda/models/types.ts b/lambdas/src/order-status-lambda/models/types.ts index 94d3be725..bc0b23561 100644 --- a/lambdas/src/order-status-lambda/models/types.ts +++ b/lambdas/src/order-status-lambda/models/types.ts @@ -1,13 +1,11 @@ +export enum StatusKind { + Order = "order", + Result = "result", +} + export enum IncomingBusinessStatus { ORDER_ACCEPTED = "order-accepted", DISPATCHED = "dispatched", RECEIVED_AT_LAB = "received-at-lab", TEST_PROCESSED = "test-processed", } - -export type IncomingOrderBusinessStatus = - | IncomingBusinessStatus.ORDER_ACCEPTED - | IncomingBusinessStatus.DISPATCHED - | IncomingBusinessStatus.RECEIVED_AT_LAB; - -export type IncomingResultBusinessStatus = IncomingBusinessStatus.TEST_PROCESSED; From b7a4061119c51ae16be4f5a5586ba20092679c04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Fri, 24 Apr 2026 13:32:28 +0200 Subject: [PATCH 17/30] remove unused method --- lambdas/src/lib/db/test-result-db-client.ts | 19 ------------------- 1 file changed, 19 deletions(-) diff --git a/lambdas/src/lib/db/test-result-db-client.ts b/lambdas/src/lib/db/test-result-db-client.ts index 3e549b22c..f5135639a 100644 --- a/lambdas/src/lib/db/test-result-db-client.ts +++ b/lambdas/src/lib/db/test-result-db-client.ts @@ -1,4 +1,3 @@ -import { ResultStatus } from "../types/status"; import { type DBClient } from "./db-client"; type TestResultStatusCode = "RESULT_AVAILABLE" | "RESULT_WITHHELD"; @@ -44,22 +43,4 @@ export class TestResultDbClient { return result?.rows[0] ?? null; } - - public async insertResultStatus( - orderId: string, - status: ResultStatus, - correlationId: string, - ): Promise { - const query = ` - INSERT INTO result_status (order_uid, status, correlation_id) - VALUES ($1::uuid, $2, $3::uuid) - ON CONFLICT (correlation_id) DO NOTHING; - `; - - await this.dbClient.query(query, [ - orderId, - status, - correlationId, - ]); - } } From 1baece36410ce270e404d61cc41c628942924f53 Mon Sep 17 00:00:00 2001 From: BenKainos-43 Date: Fri, 24 Apr 2026 14:46:37 +0100 Subject: [PATCH 18/30] refactor: Github comments resolved, fixed after review --- lambdas/package-lock.json | 2 +- lambdas/package.json | 2 +- .../cors-configuration.ts | 12 ++ .../hiv-result-processor-lambda/index.test.ts | 18 ++- .../src/hiv-result-processor-lambda/index.ts | 21 ++- .../src/hiv-result-processor-lambda/models.ts | 2 +- .../result-status-lambda-service.test.ts | 6 +- .../result-status-lambda-service.ts | 4 +- .../task-builder.ts | 4 +- .../validation-service.test.ts | 138 ------------------ .../validation-service.ts | 6 - .../hiv-result-processor-lambda/validation.ts | 8 - .../src/lib/http/lambda-http-client.test.ts | 9 +- lambdas/src/lib/http/lambda-http-client.ts | 15 +- lambdas/src/order-result-lambda/index.ts | 4 +- 15 files changed, 69 insertions(+), 182 deletions(-) create mode 100644 lambdas/src/hiv-result-processor-lambda/cors-configuration.ts delete mode 100644 lambdas/src/hiv-result-processor-lambda/validation-service.test.ts delete mode 100644 lambdas/src/hiv-result-processor-lambda/validation-service.ts delete mode 100644 lambdas/src/hiv-result-processor-lambda/validation.ts diff --git a/lambdas/package-lock.json b/lambdas/package-lock.json index 51598765b..968fac009 100644 --- a/lambdas/package-lock.json +++ b/lambdas/package-lock.json @@ -8,7 +8,7 @@ "name": "@hometest-service/lambdas", "version": "1.0.0", "dependencies": { - "@aws-sdk/client-lambda": "^3.1031.0", + "@aws-sdk/client-lambda": "3.1031.0", "@aws-sdk/client-secrets-manager": "3.1028.0", "@aws-sdk/client-sqs": "3.1028.0", "@aws-sdk/rds-signer": "3.1028.0", diff --git a/lambdas/package.json b/lambdas/package.json index c34665546..55da00ad7 100644 --- a/lambdas/package.json +++ b/lambdas/package.json @@ -20,7 +20,7 @@ "check-typescript": "tsc --noEmit" }, "dependencies": { - "@aws-sdk/client-lambda": "^3.1031.0", + "@aws-sdk/client-lambda": "3.1031.0", "@aws-sdk/client-secrets-manager": "3.1028.0", "@aws-sdk/client-sqs": "3.1028.0", "@aws-sdk/rds-signer": "3.1028.0", diff --git a/lambdas/src/hiv-result-processor-lambda/cors-configuration.ts b/lambdas/src/hiv-result-processor-lambda/cors-configuration.ts new file mode 100644 index 000000000..bbdcb07d0 --- /dev/null +++ b/lambdas/src/hiv-result-processor-lambda/cors-configuration.ts @@ -0,0 +1,12 @@ +import { type Options } from "@middy/http-cors"; + +import { defaultCorsOptions as sharedDefaultCorsOptions } from "../lib/security/cors-configuration"; + +const customCorsOptions: Options = { + methods: "POST, OPTIONS", +}; + +export const corsOptions: Options = { + ...sharedDefaultCorsOptions, + ...customCorsOptions, +}; diff --git a/lambdas/src/hiv-result-processor-lambda/index.test.ts b/lambdas/src/hiv-result-processor-lambda/index.test.ts index 2af3ace48..40d4a9d3a 100644 --- a/lambdas/src/hiv-result-processor-lambda/index.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/index.test.ts @@ -1,7 +1,7 @@ import { APIGatewayProxyEvent } from "aws-lambda"; import { createFhirErrorResponse } from "../lib/fhir-response"; -import { handler } from "./index"; +import { lambdaHandler } from "./index"; import { InterpretationCode } from "./models"; // --- Mock init.ts --- @@ -26,8 +26,8 @@ jest.mock("./task-builder", () => ({ buildTaskFromObservation: jest.fn(() => ({ mockTask: true })), })); -// --- Mock validation-service.ts --- -jest.mock("./validation-service", () => ({ +// --- Mock FHIR observation extractors --- +jest.mock("../lib/fhir-observation-extractors", () => ({ extractInterpretationCodeFromFHIRObservation: jest.fn(), })); @@ -47,7 +47,9 @@ jest.mock("../lib/fhir-response", () => ({ const { initMock } = jest.requireMock("./init"); const { buildTaskFromObservation } = jest.requireMock("./task-builder"); -const { extractInterpretationCodeFromFHIRObservation } = jest.requireMock("./validation-service"); +const { extractInterpretationCodeFromFHIRObservation } = jest.requireMock( + "../lib/fhir-observation-extractors", +); describe("hiv-results-processor handler", () => { const observation = { resourceType: "Observation" }; @@ -74,7 +76,7 @@ describe("hiv-results-processor handler", () => { it("returns 400 for invalid JSON", async () => { const badEvent = { ...event, body: "{invalid json" }; - const res = await handler(badEvent as any); + const res = await lambdaHandler(badEvent as any); expect(res.statusCode).toBe(400); expect(createFhirErrorResponse).toHaveBeenCalled(); @@ -83,7 +85,7 @@ describe("hiv-results-processor handler", () => { it("returns 200 and ignores reactive results", async () => { extractInterpretationCodeFromFHIRObservation.mockReturnValue(InterpretationCode.Abnormal); - const res = await handler(event); + const res = await lambdaHandler(event); expect(res.statusCode).toBe(200); expect(initMock.resultStatusLambdaService.sendResult).not.toHaveBeenCalled(); @@ -92,7 +94,7 @@ describe("hiv-results-processor handler", () => { it("builds task and calls status lambda for negative results", async () => { extractInterpretationCodeFromFHIRObservation.mockReturnValue(InterpretationCode.Normal); - const res = await handler(event); + const res = await lambdaHandler(event); expect(buildTaskFromObservation).toHaveBeenCalledWith( observation, @@ -109,7 +111,7 @@ describe("hiv-results-processor handler", () => { extractInterpretationCodeFromFHIRObservation.mockReturnValue(InterpretationCode.Normal); initMock.resultStatusLambdaService.sendResult.mockRejectedValueOnce(new Error("fail")); - const res = await handler(event); + const res = await lambdaHandler(event); expect(res.statusCode).toBe(500); expect(createFhirErrorResponse).toHaveBeenCalled(); diff --git a/lambdas/src/hiv-result-processor-lambda/index.ts b/lambdas/src/hiv-result-processor-lambda/index.ts index b09be6875..e606465c1 100644 --- a/lambdas/src/hiv-result-processor-lambda/index.ts +++ b/lambdas/src/hiv-result-processor-lambda/index.ts @@ -1,16 +1,24 @@ +import middy from "@middy/core"; +import cors from "@middy/http-cors"; +import httpErrorHandler from "@middy/http-error-handler"; +import httpSecurityHeaders from "@middy/http-security-headers"; import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; import { Observation } from "fhir/r4"; +import { extractInterpretationCodeFromFHIRObservation } from "../lib/fhir-observation-extractors"; import { createFhirErrorResponse, createFhirResponse } from "../lib/fhir-response"; +import { securityHeaders } from "../lib/http/security-headers"; import { getCorrelationIdFromEventHeaders } from "../lib/utils/utils"; +import { corsOptions } from "./cors-configuration"; import { init } from "./init"; import { InterpretationCode } from "./models"; import { buildTaskFromObservation } from "./task-builder"; -import { extractInterpretationCodeFromFHIRObservation } from "./validation-service"; const { commons, resultStatusLambdaService } = init(); -export const handler = async (event: APIGatewayProxyEvent): Promise => { +export const lambdaHandler = async ( + event: APIGatewayProxyEvent, +): Promise => { commons.logInfo("hiv-results-processor", "Received HIV result", { path: event.path, method: event.httpMethod, @@ -33,7 +41,9 @@ export const handler = async (event: APIGatewayProxyEvent): Promise { ); }); - it("uses 'null' as correlation ID when none is provided", async () => { + it("uses empty string as correlation ID when none is provided", async () => { mockPost.mockResolvedValueOnce(undefined); const service = new ResultStatusLambdaService(mockHttpClient); - await service.sendResult(taskPayload); + await service.sendResult(taskPayload, ""); expect(mockPost).toHaveBeenCalledWith( "result/status", taskPayload, - { "X-Correlation-Id": "null" }, + { "X-Correlation-Id": "" }, "application/fhir+json", ); }); diff --git a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts b/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts index 2c8493e5c..a081b4bcc 100644 --- a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts +++ b/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts @@ -5,11 +5,11 @@ import { type FHIRTask } from "../lib/models/fhir/fhir-service-request-type"; export class ResultStatusLambdaService { constructor(private readonly client: HttpClient) {} - async sendResult(result: FHIRTask, correlationId?: string): Promise { + async sendResult(result: FHIRTask, correlationId: string): Promise { await this.client.post( "result/status", result, - { "X-Correlation-Id": correlationId ?? "null" }, + { "X-Correlation-Id": correlationId }, "application/fhir+json", ); } diff --git a/lambdas/src/hiv-result-processor-lambda/task-builder.ts b/lambdas/src/hiv-result-processor-lambda/task-builder.ts index 108cc4ebc..68b522dfa 100644 --- a/lambdas/src/hiv-result-processor-lambda/task-builder.ts +++ b/lambdas/src/hiv-result-processor-lambda/task-builder.ts @@ -4,12 +4,12 @@ // Returns the Task objects import { Observation } from "fhir/r4"; -import { type FHIRTask } from "../lib/models/fhir/fhir-service-request-type"; import { extractOrderUidFromFHIRObservation, extractPatientIdFromFHIRObservation, extractSupplierIdFromFHIRObservation, -} from "./validation-service"; +} from "../lib/fhir-observation-extractors"; +import { type FHIRTask } from "../lib/models/fhir/fhir-service-request-type"; export function buildTaskFromObservation( observation: Observation, diff --git a/lambdas/src/hiv-result-processor-lambda/validation-service.test.ts b/lambdas/src/hiv-result-processor-lambda/validation-service.test.ts deleted file mode 100644 index 907e705df..000000000 --- a/lambdas/src/hiv-result-processor-lambda/validation-service.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import { Observation } from "fhir/r4"; - -import * as utils from "../lib/utils/utils"; -import { InterpretationCode } from "./models"; -import * as validation from "./validation-service"; - -describe("validation-service", () => { - afterEach(() => { - jest.restoreAllMocks(); - jest.clearAllMocks(); - }); - - describe("extractOrderUidFromFHIRObservation", () => { - beforeEach(() => { - jest.spyOn(utils, "isUUID").mockImplementation((id: string) => { - // Accepts only a specific UUID for test - return id === "550e8400-e29b-41d4-a716-446655440000"; - }); - }); - - it("extracts order UID from valid basedOn reference", () => { - const observation = { - basedOn: [{ reference: "ServiceRequest/550e8400-e29b-41d4-a716-446655440000" }], - } as any; - const result = validation.extractOrderUidFromFHIRObservation(observation); - expect(result).toBe("550e8400-e29b-41d4-a716-446655440000"); - }); - - it("throws if basedOn is empty array", () => { - const observation = { basedOn: [] } as any; - expect(() => validation.extractOrderUidFromFHIRObservation(observation)).toThrow( - "Observation.basedOn is empty", - ); - }); - - it("throws if basedOn[0].reference is missing", () => { - const observation = { basedOn: [{}] } as any; - expect(() => validation.extractOrderUidFromFHIRObservation(observation)).toThrow( - "Observation.basedOn[0].reference is missing", - ); - }); - - it("throws if basedOn reference format is invalid", () => { - const observation = { basedOn: [{ reference: "InvalidReferenceFormat" }] } as any; - expect(() => validation.extractOrderUidFromFHIRObservation(observation)).toThrow( - "Invalid basedOn reference format", - ); - }); - - it("throws if orderUID is not a valid UUID", () => { - jest.spyOn(utils, "isUUID").mockReturnValue(false); - const observation = { basedOn: [{ reference: "ServiceRequest/not-a-uuid" }] } as any; - expect(() => validation.extractOrderUidFromFHIRObservation(observation)).toThrow( - "Invalid orderUID format", - ); - }); - }); - - describe("extractPatientIdFromFHIRObservation", () => { - beforeEach(() => { - jest.spyOn(utils, "isUUID").mockImplementation((id: string) => { - // Accepts only a specific UUID for test - return id === "550e8400-e29b-41d4-a716-446655440001"; - }); - }); - - it("extracts patient ID from valid subject reference", () => { - const observation = { - subject: { reference: "Patient/550e8400-e29b-41d4-a716-446655440001" }, - } as any; - const result = validation.extractPatientIdFromFHIRObservation(observation); - expect(result).toBe("550e8400-e29b-41d4-a716-446655440001"); - }); - - it("throws if subject reference format is invalid", () => { - const observation = { - subject: { reference: "InvalidReferenceFormat" }, - } as any; - expect(() => validation.extractPatientIdFromFHIRObservation(observation)).toThrow( - "Invalid subject reference format", - ); - }); - - it("throws if patient ID is not a valid UUID", () => { - jest.spyOn(utils, "isUUID").mockReturnValue(false); - const observation = { - subject: { reference: "Patient/not-a-uuid" }, - } as any; - expect(() => validation.extractPatientIdFromFHIRObservation(observation)).toThrow( - "Invalid patient ID format", - ); - }); - }); - - describe("extractSupplierIdFromFHIRObservation", () => { - it("extracts supplier ID from valid performer reference", () => { - const observation = { - performer: [{ reference: "Organization/supplier-123" }], - } as any; - const result = validation.extractSupplierIdFromFHIRObservation(observation); - expect(result).toBe("supplier-123"); - }); - - it("throws if performer reference format is invalid", () => { - const observation = { - performer: [{ reference: "InvalidReferenceFormat" }], - } as any; - expect(() => validation.extractSupplierIdFromFHIRObservation(observation)).toThrow( - "Invalid performer reference format", - ); - }); - - it("throws if performer array is missing", () => { - const observation = {} as any; - expect(() => validation.extractSupplierIdFromFHIRObservation(observation)).toThrow(); - }); - - it("throws if performer[0] is missing", () => { - const observation = { performer: [] } as any; - expect(() => validation.extractSupplierIdFromFHIRObservation(observation)).toThrow(); - }); - - it("throws if performer[0].reference is missing", () => { - const observation = { performer: [{}] } as any; - expect(() => validation.extractSupplierIdFromFHIRObservation(observation)).toThrow(); - }); - }); - - describe("extractInterpretationCodeFromFHIRObservation", () => { - it("extracts interpretation code from valid observation", () => { - const observation = { - interpretation: [{ coding: [{ code: "POS" }] }], - } as any; - const result = validation.extractInterpretationCodeFromFHIRObservation(observation); - expect(result).toBe("POS"); - }); - }); -}); diff --git a/lambdas/src/hiv-result-processor-lambda/validation-service.ts b/lambdas/src/hiv-result-processor-lambda/validation-service.ts deleted file mode 100644 index 782ded9cf..000000000 --- a/lambdas/src/hiv-result-processor-lambda/validation-service.ts +++ /dev/null @@ -1,6 +0,0 @@ -export { - extractOrderUidFromFHIRObservation, - extractPatientIdFromFHIRObservation, - extractSupplierIdFromFHIRObservation, - extractInterpretationCodeFromFHIRObservation, -} from "../lib/fhir-observation-extractors"; diff --git a/lambdas/src/hiv-result-processor-lambda/validation.ts b/lambdas/src/hiv-result-processor-lambda/validation.ts deleted file mode 100644 index 488ccd4d8..000000000 --- a/lambdas/src/hiv-result-processor-lambda/validation.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - ValidationError, - ValidationResult, - ValidationResultSuccess, - ValidationResultError, - successResult, - errorResult, -} from "../lib/validation"; diff --git a/lambdas/src/lib/http/lambda-http-client.test.ts b/lambdas/src/lib/http/lambda-http-client.test.ts index d67f73d01..4b56ae220 100644 --- a/lambdas/src/lib/http/lambda-http-client.test.ts +++ b/lambdas/src/lib/http/lambda-http-client.test.ts @@ -49,12 +49,17 @@ describe("LambdaHttpClient", () => { input: expect.objectContaining({ FunctionName: "my-lambda", InvocationType: "RequestResponse", - Payload: expect.stringContaining('"httpMethod":"GET"'), }), }), ); - const sentPayload = JSON.parse(mockSend.mock.calls[0][0].input.Payload); + const payloadArg = mockSend.mock.calls[0][0].input.Payload; + const payloadString = + typeof payloadArg === "string" + ? payloadArg + : Buffer.from(payloadArg as Uint8Array).toString("utf-8"); + const sentPayload = JSON.parse(payloadString); + expect(sentPayload).toMatchObject({ httpMethod: "GET", path: "/some-path", diff --git a/lambdas/src/lib/http/lambda-http-client.ts b/lambdas/src/lib/http/lambda-http-client.ts index 8f6dc7105..7f20a949b 100644 --- a/lambdas/src/lib/http/lambda-http-client.ts +++ b/lambdas/src/lib/http/lambda-http-client.ts @@ -53,7 +53,7 @@ export class LambdaHttpClient implements HttpClient { const command = new InvokeCommand({ FunctionName: this.functionName, InvocationType: "RequestResponse", - Payload: JSON.stringify(event), + Payload: Buffer.from(JSON.stringify(event)), }); const response = await this.client.send(command); @@ -69,8 +69,9 @@ export class LambdaHttpClient implements HttpClient { if (status < 200 || status >= 300) { throw new HttpError( - `HTTP GET: Error response from ${this.functionName} ${url}: ${body}`, + `HTTP GET: Error response from ${this.functionName} ${url} `, status, + body, ); } @@ -93,7 +94,7 @@ export class LambdaHttpClient implements HttpClient { const command = new InvokeCommand({ FunctionName: this.functionName, InvocationType: "RequestResponse", - Payload: JSON.stringify(event), + Payload: Buffer.from(JSON.stringify(event)), }); const response = await this.client.send(command); @@ -108,8 +109,9 @@ export class LambdaHttpClient implements HttpClient { const resultBody = result.body; if (statusCode < 200 || statusCode >= 300) { throw new HttpError( - `HTTP POST: Error response from ${this.functionName} ${url}: ${resultBody}`, + `HTTP POST: Error response from ${this.functionName} ${url}`, statusCode, + resultBody, ); } @@ -132,7 +134,7 @@ export class LambdaHttpClient implements HttpClient { const command = new InvokeCommand({ FunctionName: this.functionName, InvocationType: "RequestResponse", - Payload: JSON.stringify(event), + Payload: Buffer.from(JSON.stringify(event)), }); const response = await this.client.send(command); @@ -147,8 +149,9 @@ export class LambdaHttpClient implements HttpClient { const resultBody = result.body; if (statusCode < 200 || statusCode >= 300) { throw new HttpError( - `HTTP POST: Error response from ${this.functionName} ${url}: ${resultBody}`, + `HTTP POST: Error response from ${this.functionName} ${url}`, statusCode, + resultBody, ); } diff --git a/lambdas/src/order-result-lambda/index.ts b/lambdas/src/order-result-lambda/index.ts index a9c1b6295..191aab56a 100644 --- a/lambdas/src/order-result-lambda/index.ts +++ b/lambdas/src/order-result-lambda/index.ts @@ -103,7 +103,9 @@ export const handler = async (event: APIGatewayProxyEvent): Promise Date: Fri, 24 Apr 2026 14:56:51 +0100 Subject: [PATCH 19/30] Extra changes from review --- .github/instructions/lambdas.instructions.md | 2 +- local-environment/infra/main.tf | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/.github/instructions/lambdas.instructions.md b/.github/instructions/lambdas.instructions.md index 8fa60770a..7e05ab200 100644 --- a/.github/instructions/lambdas.instructions.md +++ b/.github/instructions/lambdas.instructions.md @@ -101,7 +101,7 @@ export const handler = middy(lambdaHandler) - Middy middleware order is always: `httpSecurityHeaders` → `cors` → `httpErrorHandler`. - `cors` is only required if the HTTP request is coming from a browser. - - `httpSecurityHeaders` and `httpErrorHandler` is always required. + - `httpSecurityHeaders` and `httpErrorHandler` are always required. - Always pass the shared config objects: `httpSecurityHeaders(securityHeaders)` and `cors(defaultCorsOptions)` — never inline options. - Echo `X-Correlation-ID` in the response header for all JSON responses. FHIR-response lambdas (using `createFhirResponse`) do not set this header — that is an accepted divergence. - Use `createJsonResponse(statusCode, body, extraHeaders?)` for all JSON responses — never construct the response object manually. diff --git a/local-environment/infra/main.tf b/local-environment/infra/main.tf index f4968b880..c458d6cc1 100644 --- a/local-environment/infra/main.tf +++ b/local-environment/infra/main.tf @@ -527,7 +527,7 @@ module "hiv_results_lambda" { enable_cors = true cors_allow_origin = "http://localhost:3000" cors_allow_methods = ["POST", "OPTIONS"] - cors_allow_headers = ["Content-Type", "Authorization", "X-Requested-With"] + cors_allow_headers = ["Content-Type", "Authorization", "X-Requested-With", "X-Correlation-ID"] environment_variables = { RESULT_STATUS_LAMBDA_NAME = "result-status-lambda" @@ -664,7 +664,8 @@ resource "aws_api_gateway_deployment" "api_deployment" { module.session_lambda, module.order_status_lambda, module.postcode_lookup_lambda, - module.result_status_lambda + module.result_status_lambda, + module.hiv_results_lambda ] triggers = { @@ -678,7 +679,8 @@ resource "aws_api_gateway_deployment" "api_deployment" { module.session_lambda, module.order_status_lambda, module.postcode_lookup_lambda, - module.result_status_lambda + module.result_status_lambda, + module.hiv_results_lambda ])) } From cb3217d94ba70102dfb64c8361a05d54636509da Mon Sep 17 00:00:00 2001 From: BenKainos-43 Date: Fri, 24 Apr 2026 15:52:08 +0100 Subject: [PATCH 20/30] pnpm commit fix --- .gitignore | 6 ++++++ .pre-commit-config.yaml | 3 ++- lambdas/src/hiv-result-processor-lambda/index.ts | 5 ----- lambdas/src/hiv-result-processor-lambda/task-builder.ts | 4 ---- lambdas/src/lib/http/http-client.ts | 3 +-- local-environment/infra/main.tf | 2 +- scripts/config/gitleaks.toml | 2 ++ 7 files changed, 12 insertions(+), 13 deletions(-) diff --git a/.gitignore b/.gitignore index c629dcbad..f819b1e3e 100644 --- a/.gitignore +++ b/.gitignore @@ -29,6 +29,12 @@ output.json # Node node_modules dist +.pnpm-store +ui/.pnpm-store +.pnpm/ +ui/.pnpm/ +pnpm-debug.log +pnpm-error.log # Next.js .next diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index a31dc742d..1b63e74a4 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -40,6 +40,7 @@ repos: name: prettier-formatting entry: pnpm run format:pre-commit -- language: system + exclude: (\.pnpm/|pnpm-debug\.log|pnpm-error\.log) - id: eslint-ui name: ESLint (UI) @@ -92,7 +93,7 @@ repos: entry: yamllint language: python types: [file, yaml] - exclude: pnpm-lock.yaml + exclude: (pnpm-lock\.yaml|\.pnpm/|pnpm-debug\.log|pnpm-error\.log) # Markdown linting - repo: https://github.com/igorshubovych/markdownlint-cli diff --git a/lambdas/src/hiv-result-processor-lambda/index.ts b/lambdas/src/hiv-result-processor-lambda/index.ts index e606465c1..697b1368c 100644 --- a/lambdas/src/hiv-result-processor-lambda/index.ts +++ b/lambdas/src/hiv-result-processor-lambda/index.ts @@ -31,7 +31,6 @@ export const lambdaHandler = async ( return createFhirErrorResponse(400, "invalid", "Missing correlation ID", "error"); } - // 1. Parse Observation directly (no validation) let observation: Observation; try { observation = JSON.parse(event.body ?? ""); @@ -40,18 +39,15 @@ export const lambdaHandler = async ( return createFhirErrorResponse(400, "invalid", "Invalid JSON body", "error"); } - // 2. Extract interpretation code ("N" or "A") const interpretation = extractInterpretationCodeFromFHIRObservation( observation, ) as InterpretationCode; - // 3. If reactive (A) → ignore if (interpretation === InterpretationCode.Abnormal) { commons.logInfo("hiv-results-processor", "Reactive result ignored"); return createFhirResponse(200, observation); } - // 4. If negative (N) → build Task + send to status lambda if (interpretation === InterpretationCode.Normal) { try { const taskPayload = buildTaskFromObservation(observation, correlationId); @@ -64,7 +60,6 @@ export const lambdaHandler = async ( } } - // 5. Fallback (should not happen) return createFhirResponse(200, observation); }; diff --git a/lambdas/src/hiv-result-processor-lambda/task-builder.ts b/lambdas/src/hiv-result-processor-lambda/task-builder.ts index 68b522dfa..506f54dc9 100644 --- a/lambdas/src/hiv-result-processor-lambda/task-builder.ts +++ b/lambdas/src/hiv-result-processor-lambda/task-builder.ts @@ -1,7 +1,3 @@ -// Takes the raw FHIR Observation -// Extracts orderUid, patientId, supplierId -// Builds the FHIR Task payload exactly as HOTE-1100 requires -// Returns the Task objects import { Observation } from "fhir/r4"; import { diff --git a/lambdas/src/lib/http/http-client.ts b/lambdas/src/lib/http/http-client.ts index cc0eee644..319803738 100644 --- a/lambdas/src/lib/http/http-client.ts +++ b/lambdas/src/lib/http/http-client.ts @@ -19,9 +19,8 @@ export class HttpError extends Error { message: string, public readonly status: number, public readonly body?: string, - _cause?: unknown, ) { - super(message, { cause: _cause }); + super(message); this.name = "HttpError"; } } diff --git a/local-environment/infra/main.tf b/local-environment/infra/main.tf index c458d6cc1..0bb067fee 100644 --- a/local-environment/infra/main.tf +++ b/local-environment/infra/main.tf @@ -530,7 +530,7 @@ module "hiv_results_lambda" { cors_allow_headers = ["Content-Type", "Authorization", "X-Requested-With", "X-Correlation-ID"] environment_variables = { - RESULT_STATUS_LAMBDA_NAME = "result-status-lambda" + RESULT_STATUS_LAMBDA_NAME = module.result_status_lambda.lambda_function.function_name AWS_REGION = "eu-west-2" } } diff --git a/scripts/config/gitleaks.toml b/scripts/config/gitleaks.toml index 3bdaf7ab0..cf41298f2 100644 --- a/scripts/config/gitleaks.toml +++ b/scripts/config/gitleaks.toml @@ -28,6 +28,8 @@ paths = [ '''tests/WorkerUserSession*''', '''.session-cache/*''', '''ui/.next/*''', '''ui/build/*''', + '''.pnpm/*''', '''ui/.pnpm/*''', + '''.pnpm-store/*''', '''ui/.pnpm-store/*''', '''cdk.out/*''', '''.idea/*''' ] From ac59343042a1d23fdbe4687c1dc20ff41affaa5f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Mon, 27 Apr 2026 10:26:42 +0200 Subject: [PATCH 21/30] refactoring --- .../{ => builders}/task-builder.test.ts | 0 .../{ => builders}/task-builder.ts | 4 +- .../cors-configuration.ts | 12 --- .../hiv-result-processor-lambda/index.test.ts | 23 ++--- .../src/hiv-result-processor-lambda/index.ts | 32 +++---- .../hiv-result-processor-lambda/init.test.ts | 9 +- .../src/hiv-result-processor-lambda/init.ts | 7 +- .../src/hiv-result-processor-lambda/models.ts | 48 ---------- .../models/interpretation.ts | 13 +++ .../result-status-lambda-service.test.ts | 4 +- .../result-status-lambda-service.ts | 2 +- lambdas/src/lib/http/lambda-http-client.ts | 94 +++++++------------ .../order-result-lambda/validation-service.ts | 14 +-- lambdas/src/order-result-lambda/validation.ts | 8 -- local-environment/infra/main.tf | 42 ++++----- 15 files changed, 106 insertions(+), 206 deletions(-) rename lambdas/src/hiv-result-processor-lambda/{ => builders}/task-builder.test.ts (100%) rename lambdas/src/hiv-result-processor-lambda/{ => builders}/task-builder.ts (91%) delete mode 100644 lambdas/src/hiv-result-processor-lambda/cors-configuration.ts delete mode 100644 lambdas/src/hiv-result-processor-lambda/models.ts create mode 100644 lambdas/src/hiv-result-processor-lambda/models/interpretation.ts rename lambdas/src/hiv-result-processor-lambda/{ => services}/result-status-lambda-service.test.ts (93%) rename lambdas/src/hiv-result-processor-lambda/{ => services}/result-status-lambda-service.ts (82%) delete mode 100644 lambdas/src/order-result-lambda/validation.ts diff --git a/lambdas/src/hiv-result-processor-lambda/task-builder.test.ts b/lambdas/src/hiv-result-processor-lambda/builders/task-builder.test.ts similarity index 100% rename from lambdas/src/hiv-result-processor-lambda/task-builder.test.ts rename to lambdas/src/hiv-result-processor-lambda/builders/task-builder.test.ts diff --git a/lambdas/src/hiv-result-processor-lambda/task-builder.ts b/lambdas/src/hiv-result-processor-lambda/builders/task-builder.ts similarity index 91% rename from lambdas/src/hiv-result-processor-lambda/task-builder.ts rename to lambdas/src/hiv-result-processor-lambda/builders/task-builder.ts index 506f54dc9..3041d0e1f 100644 --- a/lambdas/src/hiv-result-processor-lambda/task-builder.ts +++ b/lambdas/src/hiv-result-processor-lambda/builders/task-builder.ts @@ -4,8 +4,8 @@ import { extractOrderUidFromFHIRObservation, extractPatientIdFromFHIRObservation, extractSupplierIdFromFHIRObservation, -} from "../lib/fhir-observation-extractors"; -import { type FHIRTask } from "../lib/models/fhir/fhir-service-request-type"; +} from "../../lib/fhir-observation-extractors"; +import { type FHIRTask } from "../../lib/models/fhir/fhir-service-request-type"; export function buildTaskFromObservation( observation: Observation, diff --git a/lambdas/src/hiv-result-processor-lambda/cors-configuration.ts b/lambdas/src/hiv-result-processor-lambda/cors-configuration.ts deleted file mode 100644 index bbdcb07d0..000000000 --- a/lambdas/src/hiv-result-processor-lambda/cors-configuration.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { type Options } from "@middy/http-cors"; - -import { defaultCorsOptions as sharedDefaultCorsOptions } from "../lib/security/cors-configuration"; - -const customCorsOptions: Options = { - methods: "POST, OPTIONS", -}; - -export const corsOptions: Options = { - ...sharedDefaultCorsOptions, - ...customCorsOptions, -}; diff --git a/lambdas/src/hiv-result-processor-lambda/index.test.ts b/lambdas/src/hiv-result-processor-lambda/index.test.ts index 40d4a9d3a..dfa131b33 100644 --- a/lambdas/src/hiv-result-processor-lambda/index.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/index.test.ts @@ -2,15 +2,10 @@ import { APIGatewayProxyEvent } from "aws-lambda"; import { createFhirErrorResponse } from "../lib/fhir-response"; import { lambdaHandler } from "./index"; -import { InterpretationCode } from "./models"; +import { InterpretationCode } from "./models/interpretation"; -// --- Mock init.ts --- jest.mock("./init", () => { const initMock = { - commons: { - logInfo: jest.fn(), - logError: jest.fn(), - }, resultStatusLambdaService: { sendResult: jest.fn(), }, @@ -21,17 +16,14 @@ jest.mock("./init", () => { }; }); -// --- Mock task-builder.ts --- -jest.mock("./task-builder", () => ({ +jest.mock("./builders/task-builder", () => ({ buildTaskFromObservation: jest.fn(() => ({ mockTask: true })), })); -// --- Mock FHIR observation extractors --- jest.mock("../lib/fhir-observation-extractors", () => ({ extractInterpretationCodeFromFHIRObservation: jest.fn(), })); -// --- Mock FHIR response helpers --- jest.mock("../lib/fhir-response", () => ({ createFhirErrorResponse: jest.fn((code, type, message, severity) => ({ statusCode: code, @@ -46,7 +38,7 @@ jest.mock("../lib/fhir-response", () => ({ })); const { initMock } = jest.requireMock("./init"); -const { buildTaskFromObservation } = jest.requireMock("./task-builder"); +const { buildTaskFromObservation } = jest.requireMock("./builders/task-builder"); const { extractInterpretationCodeFromFHIRObservation } = jest.requireMock( "../lib/fhir-observation-extractors", ); @@ -73,6 +65,15 @@ describe("hiv-results-processor handler", () => { jest.clearAllMocks(); }); + it("returns 400 for missing correlation ID", async () => { + const noCorrelationEvent = { ...event, headers: {} }; + + const res = await lambdaHandler(noCorrelationEvent as any); + + expect(res.statusCode).toBe(400); + expect(createFhirErrorResponse).toHaveBeenCalled(); + }); + it("returns 400 for invalid JSON", async () => { const badEvent = { ...event, body: "{invalid json" }; diff --git a/lambdas/src/hiv-result-processor-lambda/index.ts b/lambdas/src/hiv-result-processor-lambda/index.ts index 697b1368c..f08481f75 100644 --- a/lambdas/src/hiv-result-processor-lambda/index.ts +++ b/lambdas/src/hiv-result-processor-lambda/index.ts @@ -1,5 +1,4 @@ import middy from "@middy/core"; -import cors from "@middy/http-cors"; import httpErrorHandler from "@middy/http-error-handler"; import httpSecurityHeaders from "@middy/http-security-headers"; import { APIGatewayProxyEvent, APIGatewayProxyResult } from "aws-lambda"; @@ -9,33 +8,35 @@ import { extractInterpretationCodeFromFHIRObservation } from "../lib/fhir-observ import { createFhirErrorResponse, createFhirResponse } from "../lib/fhir-response"; import { securityHeaders } from "../lib/http/security-headers"; import { getCorrelationIdFromEventHeaders } from "../lib/utils/utils"; -import { corsOptions } from "./cors-configuration"; +import { buildTaskFromObservation } from "./builders/task-builder"; import { init } from "./init"; -import { InterpretationCode } from "./models"; -import { buildTaskFromObservation } from "./task-builder"; - -const { commons, resultStatusLambdaService } = init(); +import { InterpretationCode } from "./models/interpretation"; export const lambdaHandler = async ( event: APIGatewayProxyEvent, ): Promise => { - commons.logInfo("hiv-results-processor", "Received HIV result", { + const { resultStatusLambdaService } = init(); + + console.info("hiv-results-processor", "Received HIV result", { path: event.path, method: event.httpMethod, }); - const correlationId = getCorrelationIdFromEventHeaders(event); - - if (!correlationId) { - commons.logError("hiv-results-processor", "Missing correlation ID in request headers"); - return createFhirErrorResponse(400, "invalid", "Missing correlation ID", "error"); + let correlationId: string; + try { + correlationId = getCorrelationIdFromEventHeaders(event); + } catch (error) { + console.error("hiv-results-processor", "Missing or invalid correlation ID in request headers", { + error, + }); + return createFhirErrorResponse(400, "invalid", "Missing or invalid correlation ID", "error"); } let observation: Observation; try { observation = JSON.parse(event.body ?? ""); } catch (error) { - commons.logError("hiv-results-processor", "Invalid JSON in request body", { error }); + console.error("hiv-results-processor", "Invalid JSON in request body", { error }); return createFhirErrorResponse(400, "invalid", "Invalid JSON body", "error"); } @@ -44,7 +45,7 @@ export const lambdaHandler = async ( ) as InterpretationCode; if (interpretation === InterpretationCode.Abnormal) { - commons.logInfo("hiv-results-processor", "Reactive result ignored"); + console.info("hiv-results-processor", "Reactive result ignored"); return createFhirResponse(200, observation); } @@ -55,7 +56,7 @@ export const lambdaHandler = async ( return createFhirResponse(200, observation); } catch (error) { - commons.logError("hiv-results-processor", "Failed to send task to status lambda", { error }); + console.error("hiv-results-processor", "Failed to send task to status lambda", { error }); return createFhirErrorResponse(500, "exception", "Status update failed", "fatal"); } } @@ -65,5 +66,4 @@ export const lambdaHandler = async ( export const handler = middy(lambdaHandler) .use(httpSecurityHeaders(securityHeaders)) - .use(cors(corsOptions)) .use(httpErrorHandler()); diff --git a/lambdas/src/hiv-result-processor-lambda/init.test.ts b/lambdas/src/hiv-result-processor-lambda/init.test.ts index fa4b7d582..6dd6d4f47 100644 --- a/lambdas/src/hiv-result-processor-lambda/init.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/init.test.ts @@ -1,14 +1,12 @@ import { LambdaClient } from "@aws-sdk/client-lambda"; -import { ConsoleCommons } from "../lib/commons"; import { LambdaHttpClient } from "../lib/http/lambda-http-client"; import { buildEnvironment } from "./init"; -import { ResultStatusLambdaService } from "./result-status-lambda-service"; +import { ResultStatusLambdaService } from "./services/result-status-lambda-service"; jest.mock("@aws-sdk/client-lambda"); -jest.mock("../lib/commons"); jest.mock("../lib/http/lambda-http-client"); -jest.mock("./result-status-lambda-service"); +jest.mock("./services/result-status-lambda-service"); describe("hiv-results-processor init", () => { const originalEnv = process.env; @@ -25,10 +23,9 @@ describe("hiv-results-processor init", () => { process.env = originalEnv; }); - it("initializes commons and resultStatusLambdaService", () => { + it("initializes resultStatusLambdaService", () => { const env = buildEnvironment(); - expect(env.commons).toBeInstanceOf(ConsoleCommons); expect(env.resultStatusLambdaService).toBeInstanceOf(ResultStatusLambdaService); }); diff --git a/lambdas/src/hiv-result-processor-lambda/init.ts b/lambdas/src/hiv-result-processor-lambda/init.ts index f25963379..1476a3dcc 100644 --- a/lambdas/src/hiv-result-processor-lambda/init.ts +++ b/lambdas/src/hiv-result-processor-lambda/init.ts @@ -1,19 +1,15 @@ import { LambdaClient } from "@aws-sdk/client-lambda"; import { getAwsClientOptions } from "../lib/aws/aws-client-config"; -import { Commons, ConsoleCommons } from "../lib/commons"; import { LambdaHttpClient } from "../lib/http/lambda-http-client"; import { retrieveMandatoryEnvVariable } from "../lib/utils/utils"; -import { ResultStatusLambdaService } from "./result-status-lambda-service"; +import { ResultStatusLambdaService } from "./services/result-status-lambda-service"; export interface Environment { - commons: Commons; resultStatusLambdaService: ResultStatusLambdaService; } export function buildEnvironment(): Environment { - const commons = new ConsoleCommons(); - const awsRegion = retrieveMandatoryEnvVariable("AWS_REGION"); const resultStatusLambdaName = retrieveMandatoryEnvVariable("RESULT_STATUS_LAMBDA_NAME"); const lambdaClient = new LambdaClient(getAwsClientOptions(awsRegion)); @@ -21,7 +17,6 @@ export function buildEnvironment(): Environment { const resultStatusLambdaService = new ResultStatusLambdaService(lambdaHttpClient); return { - commons, resultStatusLambdaService, }; } diff --git a/lambdas/src/hiv-result-processor-lambda/models.ts b/lambdas/src/hiv-result-processor-lambda/models.ts deleted file mode 100644 index 7356fd823..000000000 --- a/lambdas/src/hiv-result-processor-lambda/models.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { z } from "zod"; - -import { - FHIRCodeableConceptSchema, - FHIRObservationSchema, - FHIRReferenceSchema, -} from "../lib/models/fhir/fhir-schemas"; -import { ResultStatus } from "../lib/types/status"; - -export enum InterpretationCode { - Normal = "N", - Abnormal = "A", -} - -export const resultCodeMapping: { - [key in InterpretationCode]: ResultStatus; -} = { - [InterpretationCode.Normal]: ResultStatus.Result_Available, - [InterpretationCode.Abnormal]: ResultStatus.Result_Withheld, -}; - -export interface Identifiers { - orderUid: string; - patientId: string; - supplierId: string; - correlationId: string; -} - -// Apply business logic specific to order results on top of schema: -// remove optionality for fields we require and only accept status of 'final' -const orderResultInterpretationCodingSchema = FHIRCodeableConceptSchema.extend({ - coding: z - .array( - z.object({ - code: z.enum(["N", "A"]), - }), - ) - .min(1), -}); - -export const orderResultFHIRObservationSchema = FHIRObservationSchema.extend({ - basedOn: z.array(FHIRReferenceSchema), - status: z.literal("final"), - subject: FHIRReferenceSchema, - performer: z.array(FHIRReferenceSchema), - valueCodeableConcept: FHIRCodeableConceptSchema, - interpretation: z.array(orderResultInterpretationCodingSchema), -}); diff --git a/lambdas/src/hiv-result-processor-lambda/models/interpretation.ts b/lambdas/src/hiv-result-processor-lambda/models/interpretation.ts new file mode 100644 index 000000000..e78bf4d43 --- /dev/null +++ b/lambdas/src/hiv-result-processor-lambda/models/interpretation.ts @@ -0,0 +1,13 @@ +import { ResultStatus } from "../../lib/types/status"; + +export enum InterpretationCode { + Normal = "N", + Abnormal = "A", +} + +export const resultCodeMapping: { + [key in InterpretationCode]: ResultStatus; +} = { + [InterpretationCode.Normal]: ResultStatus.Result_Available, + [InterpretationCode.Abnormal]: ResultStatus.Result_Withheld, +}; diff --git a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts b/lambdas/src/hiv-result-processor-lambda/services/result-status-lambda-service.test.ts similarity index 93% rename from lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts rename to lambdas/src/hiv-result-processor-lambda/services/result-status-lambda-service.test.ts index 74a7b2a37..0c81975c4 100644 --- a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/services/result-status-lambda-service.test.ts @@ -1,5 +1,5 @@ -import { HttpClient } from "../lib/http/http-client"; -import { type FHIRTask } from "../lib/models/fhir/fhir-service-request-type"; +import { HttpClient } from "../../lib/http/http-client"; +import { type FHIRTask } from "../../lib/models/fhir/fhir-service-request-type"; import { ResultStatusLambdaService } from "./result-status-lambda-service"; const mockPost = jest.fn(); diff --git a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts b/lambdas/src/hiv-result-processor-lambda/services/result-status-lambda-service.ts similarity index 82% rename from lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts rename to lambdas/src/hiv-result-processor-lambda/services/result-status-lambda-service.ts index a081b4bcc..4577536a6 100644 --- a/lambdas/src/hiv-result-processor-lambda/result-status-lambda-service.ts +++ b/lambdas/src/hiv-result-processor-lambda/services/result-status-lambda-service.ts @@ -1,6 +1,6 @@ import { HttpClient } from "src/lib/http/http-client"; -import { type FHIRTask } from "../lib/models/fhir/fhir-service-request-type"; +import { type FHIRTask } from "../../lib/models/fhir/fhir-service-request-type"; export class ResultStatusLambdaService { constructor(private readonly client: HttpClient) {} diff --git a/lambdas/src/lib/http/lambda-http-client.ts b/lambdas/src/lib/http/lambda-http-client.ts index 7f20a949b..25eb5ae2d 100644 --- a/lambdas/src/lib/http/lambda-http-client.ts +++ b/lambdas/src/lib/http/lambda-http-client.ts @@ -50,27 +50,13 @@ export class LambdaHttpClient implements HttpClient { null, ); - const command = new InvokeCommand({ - FunctionName: this.functionName, - InvocationType: "RequestResponse", - Payload: Buffer.from(JSON.stringify(event)), - }); - - const response = await this.client.send(command); + const result = await this.invoke(event, url); - if (response.FunctionError) { - throw new HttpError(`Error sending request to ${this.functionName} ${url}`, 500); - } - - const result = decodePayload(response.Payload); - - const status = result.statusCode; - const body = result.body; - - if (status < 200 || status >= 300) { + const { statusCode, body } = result; + if (statusCode < 200 || statusCode >= 300) { throw new HttpError( - `HTTP GET: Error response from ${this.functionName} ${url} `, - status, + `HTTP GET: Error response from ${this.functionName} ${url}`, + statusCode, body, ); } @@ -84,13 +70,27 @@ export class LambdaHttpClient implements HttpClient { headers: Record = {}, contentType: string = "application/json", ): Promise { - const event = buildApiGatewayEvent( - "POST", - url, - { Accept: "application/json", "Content-Type": contentType, ...headers }, - serializeBody(body), - ); + const result = await this.executePost(url, body, headers, contentType); + return JSON.parse(result.body) as T; + } + + async postRaw( + url: string, + body: unknown, + headers: Record = {}, + contentType: string = "application/json", + ): Promise { + const result = await this.executePost(url, body, headers, contentType); + return new Response(result.body, { + status: result.statusCode, + headers: result.headers as Record, + }); + } + private async invoke( + event: Partial, + url: string, + ): Promise { const command = new InvokeCommand({ FunctionName: this.functionName, InvocationType: "RequestResponse", @@ -103,27 +103,15 @@ export class LambdaHttpClient implements HttpClient { throw new HttpError(`Error sending request to ${this.functionName} ${url}`, 500); } - const result = decodePayload(response.Payload); - - const statusCode = result.statusCode; - const resultBody = result.body; - if (statusCode < 200 || statusCode >= 300) { - throw new HttpError( - `HTTP POST: Error response from ${this.functionName} ${url}`, - statusCode, - resultBody, - ); - } - - return JSON.parse(resultBody) as T; + return decodePayload(response.Payload); } - async postRaw( + private async executePost( url: string, body: unknown, - headers: Record = {}, - contentType: string = "application/json", - ): Promise { + headers: Record, + contentType: string, + ): Promise { const event = buildApiGatewayEvent( "POST", url, @@ -131,22 +119,9 @@ export class LambdaHttpClient implements HttpClient { serializeBody(body), ); - const command = new InvokeCommand({ - FunctionName: this.functionName, - InvocationType: "RequestResponse", - Payload: Buffer.from(JSON.stringify(event)), - }); - - const response = await this.client.send(command); + const result = await this.invoke(event, url); - if (response.FunctionError) { - throw new HttpError(`Error sending request to ${this.functionName} ${url}`, 500); - } - - const result = decodePayload(response.Payload); - - const statusCode = result.statusCode; - const resultBody = result.body; + const { statusCode, body: resultBody } = result; if (statusCode < 200 || statusCode >= 300) { throw new HttpError( `HTTP POST: Error response from ${this.functionName} ${url}`, @@ -155,9 +130,6 @@ export class LambdaHttpClient implements HttpClient { ); } - return new Response(resultBody, { - status: statusCode, - headers: result.headers as Record, - }); + return result; } } diff --git a/lambdas/src/order-result-lambda/validation-service.ts b/lambdas/src/order-result-lambda/validation-service.ts index 82b51166a..8b62c0a7c 100644 --- a/lambdas/src/order-result-lambda/validation-service.ts +++ b/lambdas/src/order-result-lambda/validation-service.ts @@ -8,15 +8,15 @@ import { extractPatientIdFromFHIRObservation, extractSupplierIdFromFHIRObservation, } from "../lib/fhir-observation-extractors"; -import { getCorrelationIdFromEventHeaders, isUUID } from "../lib/utils/utils"; +import { getCorrelationIdFromEventHeaders } from "../lib/utils/utils"; import { generateReadableError } from "../lib/utils/validation-utils"; import { - Identifiers, - InterpretationCode, - orderResultFHIRObservationSchema, - resultCodeMapping, -} from "./models"; -import { ValidationResult, ValidationResultError, errorResult, successResult } from "./validation"; + ValidationResult, + ValidationResultError, + errorResult, + successResult, +} from "../lib/validation"; +import { Identifiers, orderResultFHIRObservationSchema, resultCodeMapping } from "./models"; const name = "order-result-lambda"; diff --git a/lambdas/src/order-result-lambda/validation.ts b/lambdas/src/order-result-lambda/validation.ts deleted file mode 100644 index 488ccd4d8..000000000 --- a/lambdas/src/order-result-lambda/validation.ts +++ /dev/null @@ -1,8 +0,0 @@ -export { - ValidationError, - ValidationResult, - ValidationResultSuccess, - ValidationResultError, - successResult, - errorResult, -} from "../lib/validation"; diff --git a/local-environment/infra/main.tf b/local-environment/infra/main.tf index 0bb067fee..765b5d028 100644 --- a/local-environment/infra/main.tf +++ b/local-environment/infra/main.tf @@ -509,30 +509,22 @@ module "get_results_lambda" { } } -module "hiv_results_lambda" { - source = "./modules/lambda" - - project_name = var.project_name - function_name = "hiv-results-processor" - zip_path = "${path.module}/../../lambdas/dist/hiv-result-processor-lambda.zip" - lambda_role_arn = aws_iam_role.lambda_role.arn - environment = var.environment - api_gateway_id = aws_api_gateway_rest_api.api.id - api_gateway_root_resource_id = aws_api_gateway_rest_api.api.root_resource_id - api_gateway_execution_arn = aws_api_gateway_rest_api.api.execution_arn - api_path = "hiv-results" - http_method = "POST" - lambda_role_policy_attachment = aws_iam_role_policy_attachment.lambda_basic - - enable_cors = true - cors_allow_origin = "http://localhost:3000" - cors_allow_methods = ["POST", "OPTIONS"] - cors_allow_headers = ["Content-Type", "Authorization", "X-Requested-With", "X-Correlation-ID"] +resource "aws_lambda_function" "hiv_results_lambda" { + filename = "${path.module}/../../lambdas/dist/hiv-result-processor-lambda.zip" + function_name = "${var.project_name}-hiv-results-processor" + role = aws_iam_role.lambda_role.arn + handler = "index.handler" + runtime = "nodejs24.x" + source_code_hash = filebase64sha256("${path.module}/../../lambdas/dist/hiv-result-processor-lambda.zip") - environment_variables = { - RESULT_STATUS_LAMBDA_NAME = module.result_status_lambda.lambda_function.function_name - AWS_REGION = "eu-west-2" + environment { + variables = { + RESULT_STATUS_LAMBDA_NAME = module.result_status_lambda.lambda_function.function_name + AWS_REGION = "eu-west-2" + } } + + depends_on = [aws_iam_role_policy_attachment.lambda_basic] } module "order_status_lambda" { @@ -664,8 +656,7 @@ resource "aws_api_gateway_deployment" "api_deployment" { module.session_lambda, module.order_status_lambda, module.postcode_lookup_lambda, - module.result_status_lambda, - module.hiv_results_lambda + module.result_status_lambda ] triggers = { @@ -679,8 +670,7 @@ resource "aws_api_gateway_deployment" "api_deployment" { module.session_lambda, module.order_status_lambda, module.postcode_lookup_lambda, - module.result_status_lambda, - module.hiv_results_lambda + module.result_status_lambda ])) } From 0f125caf8c7bd130160df10775f9c3b127c1a826 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Mon, 27 Apr 2026 10:32:16 +0200 Subject: [PATCH 22/30] added the output variable --- local-environment/infra/outputs.tf | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/local-environment/infra/outputs.tf b/local-environment/infra/outputs.tf index a856beab5..7ebc8632c 100644 --- a/local-environment/infra/outputs.tf +++ b/local-environment/infra/outputs.tf @@ -78,6 +78,11 @@ output "local_service_mode" { value = var.local_service_mode } +output "hiv_results_processor_lambda_name" { + description = "HIV Results Processor Lambda function name" + value = aws_lambda_function.hiv_results_lambda.function_name +} + output "use_wiremock_auth" { description = "Whether local UI/tests should use WireMock-specific auth behavior" value = tostring(local.resolved_use_wiremock_auth) From cc23a6158c1931977113fb2b1a48a1d6448d152c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Mon, 27 Apr 2026 10:56:23 +0200 Subject: [PATCH 23/30] fix after review --- .../src/hiv-result-processor-lambda/index.ts | 12 +- .../hiv-result-processor-lambda/init.test.ts | 2 +- .../models/interpretation.ts | 9 -- .../result-status-lambda-service.test.ts | 4 +- .../services/result-status-lambda-service.ts | 2 +- .../fhir-observation-extractors/index.test.ts | 126 ++++++++++++++++++ .../order-result-lambda/validation-service.ts | 11 +- local-environment/infra/main.tf | 1 + 8 files changed, 147 insertions(+), 20 deletions(-) create mode 100644 lambdas/src/lib/fhir-observation-extractors/index.test.ts diff --git a/lambdas/src/hiv-result-processor-lambda/index.ts b/lambdas/src/hiv-result-processor-lambda/index.ts index f08481f75..92974b66f 100644 --- a/lambdas/src/hiv-result-processor-lambda/index.ts +++ b/lambdas/src/hiv-result-processor-lambda/index.ts @@ -12,12 +12,14 @@ import { buildTaskFromObservation } from "./builders/task-builder"; import { init } from "./init"; import { InterpretationCode } from "./models/interpretation"; +const name = "hiv-results-processor"; + export const lambdaHandler = async ( event: APIGatewayProxyEvent, ): Promise => { const { resultStatusLambdaService } = init(); - console.info("hiv-results-processor", "Received HIV result", { + console.info(name, "Received HIV result", { path: event.path, method: event.httpMethod, }); @@ -26,7 +28,7 @@ export const lambdaHandler = async ( try { correlationId = getCorrelationIdFromEventHeaders(event); } catch (error) { - console.error("hiv-results-processor", "Missing or invalid correlation ID in request headers", { + console.error(name, "Missing or invalid correlation ID in request headers", { error, }); return createFhirErrorResponse(400, "invalid", "Missing or invalid correlation ID", "error"); @@ -36,7 +38,7 @@ export const lambdaHandler = async ( try { observation = JSON.parse(event.body ?? ""); } catch (error) { - console.error("hiv-results-processor", "Invalid JSON in request body", { error }); + console.error(name, "Invalid JSON in request body", { correlationId, error }); return createFhirErrorResponse(400, "invalid", "Invalid JSON body", "error"); } @@ -45,7 +47,7 @@ export const lambdaHandler = async ( ) as InterpretationCode; if (interpretation === InterpretationCode.Abnormal) { - console.info("hiv-results-processor", "Reactive result ignored"); + console.info(name, "Reactive result ignored", { correlationId }); return createFhirResponse(200, observation); } @@ -56,7 +58,7 @@ export const lambdaHandler = async ( return createFhirResponse(200, observation); } catch (error) { - console.error("hiv-results-processor", "Failed to send task to status lambda", { error }); + console.error(name, "Failed to send task to status lambda", { correlationId, error }); return createFhirErrorResponse(500, "exception", "Status update failed", "fatal"); } } diff --git a/lambdas/src/hiv-result-processor-lambda/init.test.ts b/lambdas/src/hiv-result-processor-lambda/init.test.ts index 6dd6d4f47..0cb179017 100644 --- a/lambdas/src/hiv-result-processor-lambda/init.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/init.test.ts @@ -32,7 +32,7 @@ describe("hiv-results-processor init", () => { it("constructs LambdaClient with AWS_REGION", () => { buildEnvironment(); - expect(LambdaClient).toHaveBeenCalledWith({ region: "eu-west-2" }); + expect(LambdaClient).toHaveBeenCalledWith(expect.objectContaining({ region: "eu-west-2" })); }); it("constructs LambdaHttpClient with the LambdaClient instance and RESULT_STATUS_LAMBDA_NAME", () => { diff --git a/lambdas/src/hiv-result-processor-lambda/models/interpretation.ts b/lambdas/src/hiv-result-processor-lambda/models/interpretation.ts index e78bf4d43..1000b6c97 100644 --- a/lambdas/src/hiv-result-processor-lambda/models/interpretation.ts +++ b/lambdas/src/hiv-result-processor-lambda/models/interpretation.ts @@ -1,13 +1,4 @@ -import { ResultStatus } from "../../lib/types/status"; - export enum InterpretationCode { Normal = "N", Abnormal = "A", } - -export const resultCodeMapping: { - [key in InterpretationCode]: ResultStatus; -} = { - [InterpretationCode.Normal]: ResultStatus.Result_Available, - [InterpretationCode.Abnormal]: ResultStatus.Result_Withheld, -}; diff --git a/lambdas/src/hiv-result-processor-lambda/services/result-status-lambda-service.test.ts b/lambdas/src/hiv-result-processor-lambda/services/result-status-lambda-service.test.ts index 0c81975c4..64719ecbb 100644 --- a/lambdas/src/hiv-result-processor-lambda/services/result-status-lambda-service.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/services/result-status-lambda-service.test.ts @@ -39,7 +39,7 @@ describe("ResultStatusLambdaService", () => { expect(mockPost).toHaveBeenCalledWith( "result/status", taskPayload, - { "X-Correlation-Id": correlationId }, + { "X-Correlation-ID": correlationId }, "application/fhir+json", ); }); @@ -53,7 +53,7 @@ describe("ResultStatusLambdaService", () => { expect(mockPost).toHaveBeenCalledWith( "result/status", taskPayload, - { "X-Correlation-Id": "" }, + { "X-Correlation-ID": "" }, "application/fhir+json", ); }); diff --git a/lambdas/src/hiv-result-processor-lambda/services/result-status-lambda-service.ts b/lambdas/src/hiv-result-processor-lambda/services/result-status-lambda-service.ts index 4577536a6..dfc31d281 100644 --- a/lambdas/src/hiv-result-processor-lambda/services/result-status-lambda-service.ts +++ b/lambdas/src/hiv-result-processor-lambda/services/result-status-lambda-service.ts @@ -9,7 +9,7 @@ export class ResultStatusLambdaService { await this.client.post( "result/status", result, - { "X-Correlation-Id": correlationId }, + { "X-Correlation-ID": correlationId }, "application/fhir+json", ); } diff --git a/lambdas/src/lib/fhir-observation-extractors/index.test.ts b/lambdas/src/lib/fhir-observation-extractors/index.test.ts new file mode 100644 index 000000000..7e681c575 --- /dev/null +++ b/lambdas/src/lib/fhir-observation-extractors/index.test.ts @@ -0,0 +1,126 @@ +import { Observation } from "fhir/r4"; + +import { + extractInterpretationCodeFromFHIRObservation, + extractOrderUidFromFHIRObservation, + extractPatientIdFromFHIRObservation, + extractSupplierIdFromFHIRObservation, +} from "."; + +const validUuid = "550e8400-e29b-41d4-a716-446655440000"; +const patientUuid = "660e8400-e29b-41d4-a716-446655440001"; +const supplierUuid = "770e8400-e29b-41d4-a716-446655440002"; + +const baseObservation: Observation = { + resourceType: "Observation", + status: "final", + code: {}, + basedOn: [{ reference: `ServiceRequest/${validUuid}` }], + subject: { reference: `Patient/${patientUuid}` }, + performer: [{ reference: `Organization/${supplierUuid}` }], + interpretation: [{ coding: [{ code: "N" }] }], +}; + +describe("extractOrderUidFromFHIRObservation", () => { + it("returns the order UID from basedOn reference", () => { + expect(extractOrderUidFromFHIRObservation(baseObservation)).toBe(validUuid); + }); + + it("throws when basedOn is empty", () => { + const observation: Observation = { ...baseObservation, basedOn: [] }; + + expect(() => extractOrderUidFromFHIRObservation(observation)).toThrow( + "Observation.basedOn is empty", + ); + }); + + it("throws when basedOn reference is missing", () => { + const observation: Observation = { ...baseObservation, basedOn: [{}] }; + + expect(() => extractOrderUidFromFHIRObservation(observation)).toThrow( + "Observation.basedOn[0].reference is missing", + ); + }); + + it("throws when basedOn reference format is invalid", () => { + const observation: Observation = { + ...baseObservation, + basedOn: [{ reference: "invalid-format" }], + }; + + expect(() => extractOrderUidFromFHIRObservation(observation)).toThrow( + "Invalid basedOn reference format", + ); + }); + + it("throws when the extracted ID is not a valid UUID", () => { + const observation: Observation = { + ...baseObservation, + basedOn: [{ reference: "ServiceRequest/not-a-uuid" }], + }; + + expect(() => extractOrderUidFromFHIRObservation(observation)).toThrow( + "Invalid orderUID format", + ); + }); +}); + +describe("extractPatientIdFromFHIRObservation", () => { + it("returns the patient ID from subject reference", () => { + expect(extractPatientIdFromFHIRObservation(baseObservation)).toBe(patientUuid); + }); + + it("throws when subject reference format is invalid", () => { + const observation: Observation = { + ...baseObservation, + subject: { reference: "invalid-format" }, + }; + + expect(() => extractPatientIdFromFHIRObservation(observation)).toThrow( + "Invalid subject reference format", + ); + }); + + it("throws when the extracted ID is not a valid UUID", () => { + const observation: Observation = { + ...baseObservation, + subject: { reference: "Patient/not-a-uuid" }, + }; + + expect(() => extractPatientIdFromFHIRObservation(observation)).toThrow( + "Invalid patient ID format", + ); + }); +}); + +describe("extractSupplierIdFromFHIRObservation", () => { + it("returns the supplier ID from performer reference", () => { + expect(extractSupplierIdFromFHIRObservation(baseObservation)).toBe(supplierUuid); + }); + + it("throws when performer reference format is invalid", () => { + const observation: Observation = { + ...baseObservation, + performer: [{ reference: "invalid-format" }], + }; + + expect(() => extractSupplierIdFromFHIRObservation(observation)).toThrow( + "Invalid performer reference format", + ); + }); +}); + +describe("extractInterpretationCodeFromFHIRObservation", () => { + it("returns the interpretation code from the observation", () => { + expect(extractInterpretationCodeFromFHIRObservation(baseObservation)).toBe("N"); + }); + + it("returns 'A' for an abnormal result", () => { + const observation: Observation = { + ...baseObservation, + interpretation: [{ coding: [{ code: "A" }] }], + }; + + expect(extractInterpretationCodeFromFHIRObservation(observation)).toBe("A"); + }); +}); diff --git a/lambdas/src/order-result-lambda/validation-service.ts b/lambdas/src/order-result-lambda/validation-service.ts index 8b62c0a7c..6aad184e2 100644 --- a/lambdas/src/order-result-lambda/validation-service.ts +++ b/lambdas/src/order-result-lambda/validation-service.ts @@ -16,7 +16,12 @@ import { errorResult, successResult, } from "../lib/validation"; -import { Identifiers, orderResultFHIRObservationSchema, resultCodeMapping } from "./models"; +import { + Identifiers, + InterpretationCode, + orderResultFHIRObservationSchema, + resultCodeMapping, +} from "./models"; const name = "order-result-lambda"; @@ -107,7 +112,9 @@ export async function validateDBData( observation: Observation, testOrderResult: OrderResultSummary, ): Promise> { - const interpretationCode = extractInterpretationCodeFromFHIRObservation(observation); + const interpretationCode = extractInterpretationCodeFromFHIRObservation( + observation, + ) as InterpretationCode; const { orderUid, patientId, supplierId, correlationId } = identifiers; if (!testOrderResult) { diff --git a/local-environment/infra/main.tf b/local-environment/infra/main.tf index 765b5d028..bf537f327 100644 --- a/local-environment/infra/main.tf +++ b/local-environment/infra/main.tf @@ -519,6 +519,7 @@ resource "aws_lambda_function" "hiv_results_lambda" { environment { variables = { + NODE_OPTIONS = "--enable-source-maps" RESULT_STATUS_LAMBDA_NAME = module.result_status_lambda.lambda_function.function_name AWS_REGION = "eu-west-2" } From ddbf0cad4d63a43fd49623ad420c2d15025d2235 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Mon, 27 Apr 2026 11:15:18 +0200 Subject: [PATCH 24/30] added test to improve code coverage --- .../hiv-result-processor-lambda/index.test.ts | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/lambdas/src/hiv-result-processor-lambda/index.test.ts b/lambdas/src/hiv-result-processor-lambda/index.test.ts index dfa131b33..e12fcb113 100644 --- a/lambdas/src/hiv-result-processor-lambda/index.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/index.test.ts @@ -83,6 +83,15 @@ describe("hiv-results-processor handler", () => { expect(createFhirErrorResponse).toHaveBeenCalled(); }); + it("returns 400 when body is null", async () => { + const nullBodyEvent = { ...event, body: null }; + + const res = await lambdaHandler(nullBodyEvent as any); + + expect(res.statusCode).toBe(400); + expect(createFhirErrorResponse).toHaveBeenCalled(); + }); + it("returns 200 and ignores reactive results", async () => { extractInterpretationCodeFromFHIRObservation.mockReturnValue(InterpretationCode.Abnormal); @@ -117,4 +126,13 @@ describe("hiv-results-processor handler", () => { expect(res.statusCode).toBe(500); expect(createFhirErrorResponse).toHaveBeenCalled(); }); + + it("returns 200 without calling status lambda for unknown interpretation codes", async () => { + extractInterpretationCodeFromFHIRObservation.mockReturnValue("U"); + + const res = await lambdaHandler(event); + + expect(res.statusCode).toBe(200); + expect(initMock.resultStatusLambdaService.sendResult).not.toHaveBeenCalled(); + }); }); From ac7a489974d496d36f06478f49db389cb2258c42 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Mon, 27 Apr 2026 11:52:31 +0200 Subject: [PATCH 25/30] fix the task builder to create valid payload --- lambdas/README.md | 22 +++++++++++++++++++ .../builders/task-builder.test.ts | 15 ++++--------- .../builders/task-builder.ts | 9 +------- .../hiv-result-processor-lambda/index.test.ts | 5 +---- .../src/hiv-result-processor-lambda/index.ts | 2 +- lambdas/src/result-status-lambda/index.ts | 4 ++++ 6 files changed, 33 insertions(+), 24 deletions(-) diff --git a/lambdas/README.md b/lambdas/README.md index abe04264a..dad5bf8a7 100644 --- a/lambdas/README.md +++ b/lambdas/README.md @@ -60,6 +60,28 @@ lambdas/ - Deploy via Terraform: `pnpm run local:terraform:apply` - Functions are available at `http://localhost:4566` +### Invoking Lambdas via AWS CLI + +You can invoke a Lambda directly against LocalStack using the AWS CLI. Lambdas that are triggered via API Gateway expect an `APIGatewayProxyEvent` shape, so the payload must include a `headers` object and a `body` string. + +**Example — invoking `hometest-service-hiv-results-processor`:** + +```bash +AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test aws lambda invoke \ + --function-name hometest-service-hiv-results-processor \ + --payload '{"headers":{"x-correlation-id":"550e8400-e29b-41d4-a716-446655440008"},"body":"{\"resourceType\":\"Observation\",\"id\":\"550e8400-e29b-41d4-a716-446655440001\",\"basedOn\":[{\"reference\":\"ServiceRequest/caaf11c4-96b1-4e92-adc2-5caae9c7732d\"}],\"status\":\"final\",\"code\":{\"coding\":[{\"system\":\"http://snomed.info/sct\",\"code\":\"31676001\",\"display\":\"HIV antigen test\"}],\"text\":\"HIV antigen test\"},\"subject\":{\"reference\":\"Patient/68db68d4-8d71-4a76-9988-d55e9bef99d4\"},\"effectiveDateTime\":\"2025-11-04T15:45:00Z\",\"issued\":\"2025-11-04T16:00:00Z\",\"performer\":[{\"reference\":\"Organization/c1a2b3c4-1234-4def-8abc-123456789abc\",\"type\":\"Organization\",\"display\":\"Supplier Organization Name\"}],\"interpretation\":[{\"coding\":[{\"system\":\"http://terminology.hl7.org/CodeSystem/v3-ObservationInterpretation\",\"code\":\"N\",\"display\":\"Normal\"}],\"text\":\"Normal\"}],\"valueCodeableConcept\":{\"coding\":[{\"system\":\"http://snomed.info/sct\",\"code\":\"260415000\",\"display\":\"Not detected\"}]}}"}' \ + --cli-binary-format raw-in-base64-out \ + --endpoint-url http://localhost:4566 \ + --region eu-west-2 \ + response.json +``` + +The response is written to `response.json`. Key points: + +- `headers` — must include `x-correlation-id` as a valid UUID; the handler will throw if it is missing or invalid. +- `body` — the FHIR Observation resource serialised as a JSON string (escaped). +- `AWS_ACCESS_KEY_ID=test AWS_SECRET_ACCESS_KEY=test` — dummy credentials required by LocalStack. + ### Best Practices - Keep handler functions small and focused diff --git a/lambdas/src/hiv-result-processor-lambda/builders/task-builder.test.ts b/lambdas/src/hiv-result-processor-lambda/builders/task-builder.test.ts index 29109a2e9..4389c308c 100644 --- a/lambdas/src/hiv-result-processor-lambda/builders/task-builder.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/builders/task-builder.test.ts @@ -4,7 +4,6 @@ import { buildTaskFromObservation } from "./task-builder"; describe("buildTaskFromObservation", () => { const fixedDate = new Date("2026-04-17T10:20:30.000Z"); - const correlationId = "test-correlation-id"; const baseObservation: Pick = { resourceType: "Observation", status: "final", @@ -34,7 +33,7 @@ describe("buildTaskFromObservation", () => { performer: [{ reference: "Organization/550e8400-e29b-41d4-a716-446655440002" }], }; - const result = buildTaskFromObservation(observation, correlationId) as Task; + const result = buildTaskFromObservation(observation) as Task; expect(result).toEqual({ resourceType: "Task", @@ -43,10 +42,6 @@ describe("buildTaskFromObservation", () => { system: "https://fhir.hometest.nhs.uk/Id/order-id", value: "550e8400-e29b-41d4-a716-446655440000", }, - { - system: "https://fhir.hometest.nhs.uk/Id/correlation-id", - value: correlationId, - }, ], status: "completed", intent: "order", @@ -84,7 +79,7 @@ describe("buildTaskFromObservation", () => { performer: [{ reference: "Organization/550e8400-e29b-41d4-a716-446655440002" }], }; - expect(() => buildTaskFromObservation(observation, correlationId)).toThrow( + expect(() => buildTaskFromObservation(observation)).toThrow( "Observation.basedOn[0].reference is missing", ); }); @@ -97,9 +92,7 @@ describe("buildTaskFromObservation", () => { performer: [{ reference: "Organization/550e8400-e29b-41d4-a716-446655440002" }], }; - expect(() => buildTaskFromObservation(observation, correlationId)).toThrow( - "Invalid subject reference format", - ); + expect(() => buildTaskFromObservation(observation)).toThrow("Invalid subject reference format"); }); it("throws when performer reference is missing", () => { @@ -110,7 +103,7 @@ describe("buildTaskFromObservation", () => { performer: [{ reference: "" }], }; - expect(() => buildTaskFromObservation(observation, correlationId)).toThrow( + expect(() => buildTaskFromObservation(observation)).toThrow( "Invalid performer reference format", ); }); diff --git a/lambdas/src/hiv-result-processor-lambda/builders/task-builder.ts b/lambdas/src/hiv-result-processor-lambda/builders/task-builder.ts index 3041d0e1f..0caf18ae9 100644 --- a/lambdas/src/hiv-result-processor-lambda/builders/task-builder.ts +++ b/lambdas/src/hiv-result-processor-lambda/builders/task-builder.ts @@ -7,10 +7,7 @@ import { } from "../../lib/fhir-observation-extractors"; import { type FHIRTask } from "../../lib/models/fhir/fhir-service-request-type"; -export function buildTaskFromObservation( - observation: Observation, - correlationId: string, -): FHIRTask { +export function buildTaskFromObservation(observation: Observation): FHIRTask { const orderUid = extractOrderUidFromFHIRObservation(observation); const patientId = extractPatientIdFromFHIRObservation(observation); const supplierId = extractSupplierIdFromFHIRObservation(observation); @@ -24,10 +21,6 @@ export function buildTaskFromObservation( system: "https://fhir.hometest.nhs.uk/Id/order-id", value: orderUid, }, - { - system: "https://fhir.hometest.nhs.uk/Id/correlation-id", - value: correlationId, - }, ], status: "completed", intent: "order", diff --git a/lambdas/src/hiv-result-processor-lambda/index.test.ts b/lambdas/src/hiv-result-processor-lambda/index.test.ts index e12fcb113..5f091142a 100644 --- a/lambdas/src/hiv-result-processor-lambda/index.test.ts +++ b/lambdas/src/hiv-result-processor-lambda/index.test.ts @@ -106,10 +106,7 @@ describe("hiv-results-processor handler", () => { const res = await lambdaHandler(event); - expect(buildTaskFromObservation).toHaveBeenCalledWith( - observation, - event.headers["X-Correlation-Id"], - ); + expect(buildTaskFromObservation).toHaveBeenCalledWith(observation); expect(initMock.resultStatusLambdaService.sendResult).toHaveBeenCalledWith( { mockTask: true }, event.headers["X-Correlation-Id"], diff --git a/lambdas/src/hiv-result-processor-lambda/index.ts b/lambdas/src/hiv-result-processor-lambda/index.ts index 92974b66f..ee909db96 100644 --- a/lambdas/src/hiv-result-processor-lambda/index.ts +++ b/lambdas/src/hiv-result-processor-lambda/index.ts @@ -53,7 +53,7 @@ export const lambdaHandler = async ( if (interpretation === InterpretationCode.Normal) { try { - const taskPayload = buildTaskFromObservation(observation, correlationId); + const taskPayload = buildTaskFromObservation(observation); await resultStatusLambdaService.sendResult(taskPayload, correlationId); return createFhirResponse(200, observation); diff --git a/lambdas/src/result-status-lambda/index.ts b/lambdas/src/result-status-lambda/index.ts index e76f4fedf..b82d9769c 100644 --- a/lambdas/src/result-status-lambda/index.ts +++ b/lambdas/src/result-status-lambda/index.ts @@ -169,6 +169,10 @@ export const lambdaHandler = async ( ResultStatus.Result_Available, correlationId, ); + console.info(name, "Successfully updated order status and result status", { + orderUid, + correlationId, + }); } catch (error) { console.error(name, "Failed to update result status in database", { error, From bc52511cbe36595e5f1a314ae53fdb921a123cff Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Mon, 27 Apr 2026 12:05:41 +0200 Subject: [PATCH 26/30] added the role to invoke lambdas --- local-environment/infra/main.tf | 20 ++++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/local-environment/infra/main.tf b/local-environment/infra/main.tf index bf537f327..58a57e728 100644 --- a/local-environment/infra/main.tf +++ b/local-environment/infra/main.tf @@ -181,6 +181,26 @@ resource "aws_iam_role_policy" "lambdas_sqs_publish" { }) } +resource "aws_iam_role_policy" "lambdas_lambda_invoke" { + name = "${var.project_name}-lambdas-lambda-invoke" + role = aws_iam_role.lambda_role.id + + policy = jsonencode({ + Version = "2012-10-17" + Statement = [ + { + Effect = "Allow" + Action = [ + "lambda:InvokeFunction" + ] + Resource = [ + module.result_status_lambda.lambda_function.arn, + ] + } + ] + }) +} + resource "aws_api_gateway_rest_api" "api" { name = "${var.project_name}-api" description = "API Gateway for ${var.project_name}" From 0cc5c3611ece8d0a5d46c3ac46f4ba9680a5ff04 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Tue, 28 Apr 2026 09:56:54 +0200 Subject: [PATCH 27/30] refactoring --- lambdas/src/lib/utils/fhir-utils.ts | 5 ----- .../order-status-lambda/validation/patient-validation.ts | 7 ++++++- 2 files changed, 6 insertions(+), 6 deletions(-) delete mode 100644 lambdas/src/lib/utils/fhir-utils.ts diff --git a/lambdas/src/lib/utils/fhir-utils.ts b/lambdas/src/lib/utils/fhir-utils.ts deleted file mode 100644 index 53006fc4e..000000000 --- a/lambdas/src/lib/utils/fhir-utils.ts +++ /dev/null @@ -1,5 +0,0 @@ -export const extractIdFromReference = (reference: string): string | null => { - const parts = reference.split("/"); - - return parts.length === 2 ? parts[1] : null; -}; diff --git a/lambdas/src/order-status-lambda/validation/patient-validation.ts b/lambdas/src/order-status-lambda/validation/patient-validation.ts index c48f13fea..ea6ae32ca 100644 --- a/lambdas/src/order-status-lambda/validation/patient-validation.ts +++ b/lambdas/src/order-status-lambda/validation/patient-validation.ts @@ -1,4 +1,3 @@ -import { extractIdFromReference } from "../../lib/utils/fhir-utils"; import { ValidationResult, errorResult, successResult } from "../../lib/validation"; const name = "order-status-lambda"; @@ -36,3 +35,9 @@ export const validatePatientOwnership = ( return successResult(); }; + +function extractIdFromReference(reference: string): string | null { + const parts = reference.split("/"); + + return parts.length === 2 ? parts[1] : null; +} From bec807e27dc581925b59ab6644245eae8c5fc6bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Tue, 28 Apr 2026 10:14:11 +0200 Subject: [PATCH 28/30] improve tests --- lambdas/src/order-status-lambda/index.test.ts | 418 ++++++++++++++++++ lambdas/src/order-status-lambda/index.ts | 2 +- 2 files changed, 419 insertions(+), 1 deletion(-) create mode 100644 lambdas/src/order-status-lambda/index.test.ts diff --git a/lambdas/src/order-status-lambda/index.test.ts b/lambdas/src/order-status-lambda/index.test.ts new file mode 100644 index 000000000..974b43b9a --- /dev/null +++ b/lambdas/src/order-status-lambda/index.test.ts @@ -0,0 +1,418 @@ +import { APIGatewayProxyEvent, Context } from "aws-lambda"; + +import { IdempotencyCheckResult } from "../lib/db/order-status-db"; +import { OrderStatus } from "../lib/types/status"; +import { errorResult, successResult } from "../lib/validation"; +import { OrderStatusFHIRTask } from "./models/schemas"; +import { IncomingBusinessStatus } from "./models/types"; + +const mockInit = jest.fn(); + +const mockGetPatientIdFromOrder = jest.fn(); +const mockCheckIdempotency = jest.fn(); +const mockAddOrderStatusUpdate = jest.fn(); +const mockNotify = jest.fn(); +const mockHandleReminderOrderStatusUpdated = jest.fn(); +const mockInsertResultStatusCommand = jest.fn(); + +const mockValidateAndExtractCorrelationId = jest.fn(); +const mockValidateAndExtractTask = jest.fn(); +const mockValidatePatientOwnership = jest.fn(); + +jest.mock("./init", () => ({ + init: mockInit, +})); + +jest.mock("./validation/correlation-id-validation", () => ({ + validateAndExtractCorrelationId: mockValidateAndExtractCorrelationId, +})); + +jest.mock("./validation/task-validation", () => ({ + validateAndExtractTask: mockValidateAndExtractTask, +})); + +jest.mock("./validation/patient-validation", () => ({ + validatePatientOwnership: mockValidatePatientOwnership, +})); + +const MOCK_CORRELATION_ID = "123e4567-e89b-12d3-a456-426614174000"; +const MOCK_ORDER_UID = "550e8400-e29b-41d4-a716-446655440000"; +const MOCK_PATIENT_UID = "patient-123"; +const MOCK_BUSINESS_STATUS = IncomingBusinessStatus.DISPATCHED; + +describe("Order Status Lambda Handler", () => { + let handler: (event: APIGatewayProxyEvent, context: Context) => Promise; + let mockEvent: Partial; + + const validTask: OrderStatusFHIRTask = { + resourceType: "Task", + status: "in-progress", + intent: "order", + identifier: [ + { + value: MOCK_ORDER_UID, + }, + ], + for: { + reference: `Patient/${MOCK_PATIENT_UID}`, + }, + lastModified: "2024-01-15T10:00:00Z", + businessStatus: { + text: MOCK_BUSINESS_STATUS, + }, + }; + + beforeEach(async () => { + jest.resetModules(); + jest.clearAllMocks(); + + mockEvent = {}; + + mockValidateAndExtractCorrelationId.mockReturnValue(successResult(MOCK_CORRELATION_ID)); + mockValidateAndExtractTask.mockReturnValue(successResult(validTask)); + mockValidatePatientOwnership.mockReturnValue(successResult()); + + mockGetPatientIdFromOrder.mockResolvedValue(MOCK_PATIENT_UID); + mockCheckIdempotency.mockResolvedValue({ isDuplicate: false }); + mockAddOrderStatusUpdate.mockResolvedValue(undefined); + mockNotify.mockResolvedValue(undefined); + mockHandleReminderOrderStatusUpdated.mockResolvedValue(undefined); + mockInsertResultStatusCommand.mockResolvedValue(undefined); + + mockInit.mockReturnValue({ + orderStatusDb: { + getPatientIdFromOrder: mockGetPatientIdFromOrder, + checkIdempotency: mockCheckIdempotency, + addOrderStatusUpdate: mockAddOrderStatusUpdate, + }, + orderStatusNotifyService: { + dispatch: mockNotify, + }, + orderStatusReminderService: { + handleOrderStatusUpdated: mockHandleReminderOrderStatusUpdated, + }, + insertResultStatusCommand: { + execute: mockInsertResultStatusCommand, + }, + }); + + const module = await import("./index"); + + handler = module.lambdaHandler; + }); + + describe("Validation Delegation", () => { + it("should return FHIR error when correlation ID validation fails", async () => { + mockValidateAndExtractCorrelationId.mockReturnValueOnce( + errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Correlation ID is missing or invalid", + severity: "error", + }), + ); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(400); + const body = JSON.parse(result.body); + expect(body.resourceType).toBe("OperationOutcome"); + expect(body.issue[0].code).toBe("invalid"); + expect(body.issue[0].diagnostics).toMatch(/correlation id/i); + }); + + it("should return FHIR error when task validation fails", async () => { + mockValidateAndExtractTask.mockReturnValueOnce( + errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Request body is required", + severity: "error", + }), + ); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(400); + const body = JSON.parse(result.body); + expect(body.resourceType).toBe("OperationOutcome"); + expect(body.issue[0].code).toBe("invalid"); + }); + + it("should return FHIR error when patient ownership validation fails", async () => { + mockValidatePatientOwnership.mockReturnValueOnce( + errorResult({ + errorCode: 400, + errorType: "invalid", + errorMessage: "Patient ID does not match the order", + severity: "error", + }), + ); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(400); + const body = JSON.parse(result.body); + expect(body.resourceType).toBe("OperationOutcome"); + expect(body.issue[0].diagnostics).toContain("Patient ID does not match"); + }); + }); + + describe("Order Existence", () => { + it("should return 404 when order does not exist", async () => { + mockGetPatientIdFromOrder.mockResolvedValueOnce(null); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(404); + + const body = JSON.parse(result.body); + + expect(body.resourceType).toBe("OperationOutcome"); + expect(body.issue[0].code).toBe("not-found"); + expect(body.issue[0].diagnostics).toContain("not found"); + }); + + it("should proceed when order exists", async () => { + mockGetPatientIdFromOrder.mockResolvedValueOnce(MOCK_PATIENT_UID); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(mockGetPatientIdFromOrder).toHaveBeenCalledWith(MOCK_ORDER_UID); + expect(result.statusCode).toBe(201); + }); + }); + + describe("Idempotency via Correlation ID", () => { + it("should detect duplicate updates with same correlation ID", async () => { + mockCheckIdempotency.mockResolvedValueOnce({ + isDuplicate: true, + } satisfies Partial); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(200); + expect(mockCheckIdempotency).toHaveBeenCalledWith(MOCK_ORDER_UID, MOCK_CORRELATION_ID); + expect(mockHandleReminderOrderStatusUpdated).not.toHaveBeenCalled(); + expect(mockNotify).not.toHaveBeenCalled(); + }); + + it("should process new updates with a different correlation ID", async () => { + const newCorrelationId = "mock-new-correlation-id-123"; + mockValidateAndExtractCorrelationId.mockReturnValueOnce(successResult(newCorrelationId)); + + mockCheckIdempotency.mockResolvedValueOnce({ + isDuplicate: false, + } satisfies IdempotencyCheckResult); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + + expect(mockAddOrderStatusUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + correlationId: newCorrelationId, + }), + ); + }); + }); + + describe("Timestamp Handling", () => { + it("should use lastModified timestamp when it is older than the latest update", async () => { + const mockedLastModifiedTimestamp = "2024-01-15T08:00:00Z"; + mockValidateAndExtractTask.mockReturnValueOnce( + successResult({ ...validTask, lastModified: mockedLastModifiedTimestamp }), + ); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + + expect(mockAddOrderStatusUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + createdAt: mockedLastModifiedTimestamp, + }), + ); + }); + + it("should use lastModified timestamp when it is newer than the latest update", async () => { + const mockedLastModifiedTimestamp = "2024-01-15T11:00:00Z"; + mockValidateAndExtractTask.mockReturnValueOnce( + successResult({ ...validTask, lastModified: mockedLastModifiedTimestamp }), + ); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + + expect(mockAddOrderStatusUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + createdAt: mockedLastModifiedTimestamp, + }), + ); + }); + }); + + describe("Successful Update", () => { + it("should return 201 OK with updated Task when all validations pass", async () => { + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + expect(result.headers?.["Content-Type"]).toBe("application/fhir+json"); + + const body = JSON.parse(result.body); + + expect(body.resourceType).toBe("Task"); + expect(body.status).toBe(validTask.status); + expect(body.for.reference).toBe(`Patient/${MOCK_PATIENT_UID}`); + }); + + it("should call addOrderStatusUpdate with correct parameters", async () => { + await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(mockAddOrderStatusUpdate).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: MOCK_ORDER_UID, + statusCode: OrderStatus.Dispatched, + createdAt: validTask.lastModified, + correlationId: MOCK_CORRELATION_ID, + }), + ); + }); + + it("should delegate post-update side effects to the notification service", async () => { + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + patientId: MOCK_PATIENT_UID, + correlationId: MOCK_CORRELATION_ID, + orderId: MOCK_ORDER_UID, + statusCode: OrderStatus.Dispatched, + }), + ); + }); + + it("should still delegate non-dispatched statuses to the notification service", async () => { + mockValidateAndExtractTask.mockReturnValueOnce( + successResult({ + ...validTask, + businessStatus: { text: IncomingBusinessStatus.RECEIVED_AT_LAB }, + }), + ); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: OrderStatus.Received, + }), + ); + }); + + it("should delegate confirmed statuses to the notification service", async () => { + mockValidateAndExtractTask.mockReturnValueOnce( + successResult({ + ...validTask, + businessStatus: { text: IncomingBusinessStatus.ORDER_ACCEPTED }, + }), + ); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + expect(mockNotify).toHaveBeenCalledWith( + expect.objectContaining({ + statusCode: OrderStatus.Confirmed, + }), + ); + }); + + it("should delegate reminder scheduling to the reminder service", async () => { + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + expect(mockHandleReminderOrderStatusUpdated).toHaveBeenCalledWith( + expect.objectContaining({ + orderId: MOCK_ORDER_UID, + correlationId: MOCK_CORRELATION_ID, + statusCode: OrderStatus.Dispatched, + triggeredAt: validTask.lastModified, + }), + ); + }); + + it("should return 201 when notification service fails after a successful status update", async () => { + mockNotify.mockRejectedValueOnce(new Error("Unexpected side effect error")); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + }); + }); + + describe("Result Status Update (TEST_PROCESSED)", () => { + beforeEach(() => { + mockValidateAndExtractTask.mockReturnValue( + successResult({ + ...validTask, + businessStatus: { text: IncomingBusinessStatus.TEST_PROCESSED }, + }), + ); + }); + + it("should return 201 and call insertResultStatusCommand when status is TEST_PROCESSED", async () => { + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + expect(mockInsertResultStatusCommand).toHaveBeenCalledWith( + MOCK_ORDER_UID, + expect.any(String), + MOCK_CORRELATION_ID, + ); + expect(mockNotify).not.toHaveBeenCalled(); + expect(mockHandleReminderOrderStatusUpdated).not.toHaveBeenCalled(); + }); + + it("should still return 201 when insertResultStatusCommand throws", async () => { + mockInsertResultStatusCommand.mockRejectedValueOnce(new Error("Result status insert failed")); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(201); + expect(mockNotify).not.toHaveBeenCalled(); + expect(mockHandleReminderOrderStatusUpdated).not.toHaveBeenCalled(); + }); + }); + + describe("Error Handling", () => { + it("should return 500 with OperationOutcome for database errors", async () => { + mockGetPatientIdFromOrder.mockRejectedValueOnce(new Error("Database connection failed")); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(500); + + const body = JSON.parse(result.body); + + expect(body.resourceType).toBe("OperationOutcome"); + expect(body.issue[0].severity).toBe("fatal"); + expect(body.issue[0].code).toBe("exception"); + }); + + it("should return 500 with OperationOutcome for unexpected errors", async () => { + mockCheckIdempotency.mockRejectedValueOnce(new Error("Unexpected error")); + + const result = await handler(mockEvent as APIGatewayProxyEvent, {} as Context); + + expect(result.statusCode).toBe(500); + + const body = JSON.parse(result.body); + + expect(body.resourceType).toBe("OperationOutcome"); + expect(body.issue[0].severity).toBe("fatal"); + }); + }); +}); diff --git a/lambdas/src/order-status-lambda/index.ts b/lambdas/src/order-status-lambda/index.ts index 473d437f4..4f0aa077f 100644 --- a/lambdas/src/order-status-lambda/index.ts +++ b/lambdas/src/order-status-lambda/index.ts @@ -58,7 +58,7 @@ export const lambdaHandler = async ( const orderPatientId = await orderStatusDb.getPatientIdFromOrder(orderId); if (!orderPatientId) { - console.error(name, "Order not found", { orderId }); + console.error(name, "Order not found", logContext); return createFhirErrorResponse( 404, From aca53e810559f85fd451b6528ddf407d7215b92f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Jaros=C5=82aw=20Rejterada?= Date: Tue, 28 Apr 2026 10:22:03 +0200 Subject: [PATCH 29/30] add error to log --- lambdas/src/order-status-lambda/index.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lambdas/src/order-status-lambda/index.ts b/lambdas/src/order-status-lambda/index.ts index 4f0aa077f..9692e6641 100644 --- a/lambdas/src/order-status-lambda/index.ts +++ b/lambdas/src/order-status-lambda/index.ts @@ -93,9 +93,10 @@ export const lambdaHandler = async ( try { await insertResultStatusCommand.execute(orderId, resolved.status, correlationId); } catch (error) { - console.warn(name, "Failed to update result status", { + console.error(name, "Failed to update result status", { ...logContext, resultStatus: resolved.status, + error, }); } From 8a29ba468c03a15fe4a37c96c41f41355f2f7eb4 Mon Sep 17 00:00:00 2001 From: Masha-kainos <256341953+Masha-kainos@users.noreply.github.com> Date: Tue, 28 Apr 2026 14:45:31 +0100 Subject: [PATCH 30/30] Add test for order processed status --- tests/models/TestResult.ts | 2 +- tests/test-data/OrderStatusTypes.ts | 1 + tests/tests/api/OrderStatusUpdate.spec.ts | 19 +++++++++++++++++-- 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/tests/models/TestResult.ts b/tests/models/TestResult.ts index 62e019d3c..3e95f6eca 100644 --- a/tests/models/TestResult.ts +++ b/tests/models/TestResult.ts @@ -1,4 +1,4 @@ -export type ResultStatus = 'RESULT_AVAILABLE' | 'RESULT_WITHHELD'; +export type ResultStatus = "RESULT_AVAILABLE" | "RESULT_WITHHELD" | "RESULT_PROCESSED"; export interface TestResult { order_uid: string; diff --git a/tests/test-data/OrderStatusTypes.ts b/tests/test-data/OrderStatusTypes.ts index 9e9987f9f..1c8f7d069 100644 --- a/tests/test-data/OrderStatusTypes.ts +++ b/tests/test-data/OrderStatusTypes.ts @@ -15,6 +15,7 @@ export class OrderStatusTestData { static readonly BUSINESS_STATUS_ORDER_ACCEPTED = "order-accepted"; static readonly BUSINESS_STATUS_DISPATCHED = "dispatched"; static readonly BUSINESS_STATUS_RECEIVED_AT_LAB = "received-at-lab"; + static readonly BUSINESS_STATUS_TEST_PROCESSED = "test-processed"; static readonly EXPECTED_STATUS_CODE_CONFIRMED = "CONFIRMED"; static readonly EXPECTED_STATUS_CODE_DISPATCHED = "DISPATCHED"; static readonly EXPECTED_STATUS_CODE_RECEIVED = "RECEIVED"; diff --git a/tests/tests/api/OrderStatusUpdate.spec.ts b/tests/tests/api/OrderStatusUpdate.spec.ts index 05d05fcc9..5d49d0d75 100644 --- a/tests/tests/api/OrderStatusUpdate.spec.ts +++ b/tests/tests/api/OrderStatusUpdate.spec.ts @@ -35,17 +35,18 @@ test.describe("Order Status Update API", { tag: ["@API", "@db"] }, () => { await testOrderDb.insertConsent(orderUid); }); - test.afterEach(async ({ testOrderDb }) => { + test.afterEach(async ({ testOrderDb, testResultDb }) => { await testOrderDb.deleteOrderStatusByUid(orderUid); await testOrderDb.deleteConsentByOrderUid(orderUid); await testOrderDb.deleteOrderByUid(orderUid); + await testResultDb.deleteResultStatusByUid(orderUid); await testOrderDb.deletePatientMapping(nhsNumber, birthDate); }); test( "success (201) persists order status updates", { tag: ["@API"] }, - async ({ orderStatusApi, testOrderDb }) => { + async ({ orderStatusApi, testOrderDb, testResultDb }) => { const confirmedResponse = await orderStatusApi.updateOrderStatus( orderStatusPayload(orderUid, patientUid, defaultStatus, defaultIntent, { businessStatus: { text: OrderStatusTestData.BUSINESS_STATUS_ORDER_ACCEPTED }, @@ -84,6 +85,20 @@ test.describe("Order Status Update API", { tag: ["@API", "@db"] }, () => { const { statusCode: receivedStatusCode } = await testOrderDb.getLatestOrderStatusWithCountByOrderUid(orderUid); expect(receivedStatusCode).toBe(OrderStatusTestData.EXPECTED_STATUS_CODE_RECEIVED); + + const processedResponse = await orderStatusApi.updateOrderStatus( + orderStatusPayload(orderUid, patientUid, defaultStatus, defaultIntent, { + businessStatus: { text: OrderStatusTestData.BUSINESS_STATUS_TEST_PROCESSED }, + }), + buildHeaders(randomUUID()), + ); + + orderStatusApi.validateResponse(processedResponse, 201); + + const resultStatus = await testResultDb.getLatestResultStatusByOrderUid(orderUid); + expect(resultStatus).toBe("RESULT_PROCESSED"); + const orderStatus = await testOrderDb.getLatestOrderStatusWithCountByOrderUid(orderUid); + expect(orderStatus.statusCode).toBe(OrderStatusTestData.EXPECTED_STATUS_CODE_RECEIVED); }, ); });