diff --git a/.github/actions/test-types.json b/.github/actions/test-types.json index 5530c31c8..0aa79285d 100644 --- a/.github/actions/test-types.json +++ b/.github/actions/test-types.json @@ -1,3 +1,4 @@ [ - "component" + "component", + "sandbox" ] diff --git a/Makefile b/Makefile index 0d094fbb4..1d392c18c 100644 --- a/Makefile +++ b/Makefile @@ -104,6 +104,9 @@ config:: _install-dependencies version # Configure development environment (main test-component: (cd tests && npm install && npm run test:component) +test-sandbox: + (cd tests && npm install && npm run test:sandbox) + test-performance: (cd tests && npm install && npm run test:performance) diff --git a/package-lock.json b/package-lock.json index 84ca65626..44b6fe221 100644 --- a/package-lock.json +++ b/package-lock.json @@ -710,6 +710,60 @@ "node": ">=20.0.0" } }, + "node_modules/@aws-sdk/client-cloudwatch-logs": { + "version": "3.1003.0", + "resolved": "https://registry.npmjs.org/@aws-sdk/client-cloudwatch-logs/-/client-cloudwatch-logs-3.1003.0.tgz", + "integrity": "sha512-cwIBBA40NIK4P7JpPY5y5KqaxikV6YeoNwH46ajVPBfS7Sq3GoSW1TRzLwZ6JVNPRAbocVHPWn7zzgJh4NE3Fw==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@aws-crypto/sha256-browser": "5.2.0", + "@aws-crypto/sha256-js": "5.2.0", + "@aws-sdk/core": "^3.973.18", + "@aws-sdk/credential-provider-node": "^3.972.17", + "@aws-sdk/middleware-host-header": "^3.972.7", + "@aws-sdk/middleware-logger": "^3.972.7", + "@aws-sdk/middleware-recursion-detection": "^3.972.7", + "@aws-sdk/middleware-user-agent": "^3.972.18", + "@aws-sdk/region-config-resolver": "^3.972.7", + "@aws-sdk/types": "^3.973.5", + "@aws-sdk/util-endpoints": "^3.996.4", + "@aws-sdk/util-user-agent-browser": "^3.972.7", + "@aws-sdk/util-user-agent-node": "^3.973.3", + "@smithy/config-resolver": "^4.4.10", + "@smithy/core": "^3.23.8", + "@smithy/eventstream-serde-browser": "^4.2.11", + "@smithy/eventstream-serde-config-resolver": "^4.3.11", + "@smithy/eventstream-serde-node": "^4.2.11", + "@smithy/fetch-http-handler": "^5.3.13", + "@smithy/hash-node": "^4.2.11", + "@smithy/invalid-dependency": "^4.2.11", + "@smithy/middleware-content-length": "^4.2.11", + "@smithy/middleware-endpoint": "^4.4.22", + "@smithy/middleware-retry": "^4.4.39", + "@smithy/middleware-serde": "^4.2.12", + "@smithy/middleware-stack": "^4.2.11", + "@smithy/node-config-provider": "^4.3.11", + "@smithy/node-http-handler": "^4.4.14", + "@smithy/protocol-http": "^5.3.11", + "@smithy/smithy-client": "^4.12.2", + "@smithy/types": "^4.13.0", + "@smithy/url-parser": "^4.2.11", + "@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.38", + "@smithy/util-defaults-mode-node": "^4.2.41", + "@smithy/util-endpoints": "^3.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-retry": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", + "tslib": "^2.6.2" + }, + "engines": { + "node": ">=20.0.0" + } + }, "node_modules/@aws-sdk/client-dynamodb": { "version": "3.1002.0", "resolved": "https://registry.npmjs.org/@aws-sdk/client-dynamodb/-/client-dynamodb-3.1002.0.tgz", @@ -1055,9 +1109,9 @@ "@smithy/signature-v4": "^5.3.10", "@smithy/smithy-client": "^4.12.1", "@smithy/types": "^4.13.0", - "@smithy/util-base64": "^4.3.1", - "@smithy/util-middleware": "^4.2.10", - "@smithy/util-utf8": "^4.2.1", + "@smithy/util-base64": "^4.3.2", + "@smithy/util-middleware": "^4.2.11", + "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" }, "engines": { @@ -1361,13 +1415,13 @@ } }, "node_modules/@aws-sdk/middleware-host-header": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.6.tgz", - "integrity": "sha512-5XHwjPH1lHB+1q4bfC7T8Z5zZrZXfaLcjSMwTd1HPSPrCmPFMbg3UQ5vgNWcVj0xoX4HWqTGkSf2byrjlnRg5w==", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-host-header/-/middleware-host-header-3.972.7.tgz", + "integrity": "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/protocol-http": "^5.3.10", + "@aws-sdk/types": "^3.973.5", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -1390,12 +1444,12 @@ } }, "node_modules/@aws-sdk/middleware-logger": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.6.tgz", - "integrity": "sha512-iFnaMFMQdljAPrvsCVKYltPt2j40LQqukAbXvW7v0aL5I+1GO7bZ/W8m12WxW3gwyK5p5u1WlHg8TSAizC5cZw==", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-logger/-/middleware-logger-3.972.7.tgz", + "integrity": "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", + "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -1404,14 +1458,14 @@ } }, "node_modules/@aws-sdk/middleware-recursion-detection": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.6.tgz", - "integrity": "sha512-dY4v3of5EEMvik6+UDwQ96KfUFDk8m1oZDdkSc5lwi4o7rFrjnv0A+yTV+gu230iybQZnKgDLg/rt2P3H+Vscw==", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/middleware-recursion-detection/-/middleware-recursion-detection-3.972.7.tgz", + "integrity": "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", + "@aws-sdk/types": "^3.973.5", "@aws/lambda-invoke-store": "^0.2.2", - "@smithy/protocol-http": "^5.3.10", + "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -1502,6 +1556,7 @@ "@smithy/core": "^3.23.7", "@smithy/protocol-http": "^5.3.10", "@smithy/types": "^4.13.0", + "@smithy/util-retry": "^4.2.11", "tslib": "^2.6.2" }, "engines": { @@ -1558,14 +1613,14 @@ } }, "node_modules/@aws-sdk/region-config-resolver": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.6.tgz", - "integrity": "sha512-Aa5PusHLXAqLTX1UKDvI3pHQJtIsF7Q+3turCHqfz/1F61/zDMWfbTC8evjhrrYVAtz9Vsv3SJ/waSUeu7B6gw==", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/region-config-resolver/-/region-config-resolver-3.972.7.tgz", + "integrity": "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", - "@smithy/config-resolver": "^4.4.9", - "@smithy/node-config-provider": "^4.3.10", + "@aws-sdk/types": "^3.973.5", + "@smithy/config-resolver": "^4.4.10", + "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, @@ -1628,9 +1683,9 @@ } }, "node_modules/@aws-sdk/types": { - "version": "3.973.4", - "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.4.tgz", - "integrity": "sha512-RW60aH26Bsc016Y9B98hC0Plx6fK5P2v/iQYwMzrSjiDh1qRMUCP6KrXHYEHe3uFvKiOC93Z9zk4BJsUi6Tj1Q==", + "version": "3.973.5", + "resolved": "https://registry.npmjs.org/@aws-sdk/types/-/types-3.973.5.tgz", + "integrity": "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ==", "license": "Apache-2.0", "dependencies": { "@smithy/types": "^4.13.0", @@ -1668,15 +1723,15 @@ } }, "node_modules/@aws-sdk/util-endpoints": { - "version": "3.996.3", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.3.tgz", - "integrity": "sha512-yWIQSNiCjykLL+ezN5A+DfBb1gfXTytBxm57e64lYmwxDHNmInYHRJYYRAGWG1o77vKEiWaw4ui28e3yb1k5aQ==", + "version": "3.996.4", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-endpoints/-/util-endpoints-3.996.4.tgz", + "integrity": "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", + "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", - "@smithy/url-parser": "^4.2.10", - "@smithy/util-endpoints": "^3.3.1", + "@smithy/url-parser": "^4.2.11", + "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" }, "engines": { @@ -1711,12 +1766,12 @@ } }, "node_modules/@aws-sdk/util-user-agent-browser": { - "version": "3.972.6", - "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.6.tgz", - "integrity": "sha512-Fwr/llD6GOrFgQnKaI2glhohdGuBDfHfora6iG9qsBBBR8xv1SdCSwbtf5CWlUdCw5X7g76G/9Hf0Inh0EmoxA==", + "version": "3.972.7", + "resolved": "https://registry.npmjs.org/@aws-sdk/util-user-agent-browser/-/util-user-agent-browser-3.972.7.tgz", + "integrity": "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw==", "license": "Apache-2.0", "dependencies": { - "@aws-sdk/types": "^3.973.4", + "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" @@ -23811,6 +23866,9 @@ "randexp": "^0.5.3", "undici-types": "^7.10.0", "zod": "^4.1.11" + }, + "devDependencies": { + "@aws-sdk/client-cloudwatch-logs": "^3.1003.0" } }, "tests/contracts/provider": { diff --git a/tests/component-tests/events-tests/event-subscription.spec.ts b/tests/component-tests/events-tests/event-subscription.spec.ts new file mode 100644 index 000000000..5547917db --- /dev/null +++ b/tests/component-tests/events-tests/event-subscription.spec.ts @@ -0,0 +1,166 @@ +import { expect, test } from "@playwright/test"; +import { sendSnsEvent } from "tests/helpers/send-sns-event"; +import { createPreparedV1Event } from "tests/helpers/event-fixtures"; +import { randomUUID } from "node:crypto"; +import { logger } from "tests/helpers/pino-logger"; +import { createValidRequestHeaders } from "tests/constants/request-headers"; +import getRestApiGatewayBaseUrl from "tests/helpers/aws-gateway-helper"; +import { SUPPLIER_LETTERS, envName } from "tests/constants/api-constants"; +import { + pollSupplierAllocatorLogForResolvedSpec, + pollUpsertLetterLogForError, +} from "tests/helpers/aws-cloudwatch-helper"; +import { supplierDataSetup } from "tests/helpers/suppliers-setup-helper"; + +let baseUrl: string; + +test.beforeAll(async () => { + baseUrl = await getRestApiGatewayBaseUrl(); +}); + +test.describe("Event Subscription SNS Tests", () => { + test.setTimeout(180_000); // 3 minutes for long running polling + test(`Verify that the publish event to nhs-${envName}-supapi-eventsub topic inserts data into db`, async ({ + request, + }) => { + const domainId = randomUUID(); + logger.info(`Testing event subscription with domainId: ${domainId}`); + const preparedEvent = createPreparedV1Event({ domainId }); + const response = await sendSnsEvent(preparedEvent); + const RETRY_DELAY_MS = 30_000; + + expect(response.MessageId).toBeTruthy(); + + // poll supplier allocator to check if supplier has been allocated + const message = await pollSupplierAllocatorLogForResolvedSpec(domainId); + const supplierAllocatorLog = JSON.parse(message) as { + msg?: { supplierSpec?: { supplierId?: string } }; + }; + const supplierId = supplierAllocatorLog.msg?.supplierSpec?.supplierId; + + logger.info( + `Supplier ${supplierId} allocated for domainId ${domainId} in supplier allocator lambda`, + ); + if (!supplierId) { + throw new Error("supplierId was not found in supplier allocator log"); + } + + // check if supplier exists in suppliers table + await supplierDataSetup(supplierId); + + const headers = createValidRequestHeaders(supplierId); + let statusCode = 0; + let letterStatus: string | undefined; + + for (let attempt = 1; attempt <= 3; attempt++) { + const getLetterResponse = await request.get( + `${baseUrl}/${SUPPLIER_LETTERS}/${domainId}`, + { + headers, + }, + ); + + statusCode = getLetterResponse.status(); + const responseBody = (await getLetterResponse.json()) as { + data?: { attributes?: { status?: string } }; + }; + letterStatus = responseBody.data?.attributes?.status; + + if (statusCode === 200 && letterStatus === "PENDING") { + logger.info( + `Attempt ${attempt}: Received status code ${statusCode} for domainId: ${domainId}`, + ); + break; + } + + if (attempt < 3) { + logger.info( + `Attempt ${attempt}: Received status code ${statusCode} for domainId: ${domainId}. Retrying after ${RETRY_DELAY_MS / 1000} seconds...`, + ); + await new Promise((resolve) => { + setTimeout(resolve, RETRY_DELAY_MS); // Wait for 30 seconds before the next attempt + }); + } + } + expect(statusCode).toBe(200); + expect(letterStatus).toBe("PENDING"); + }); + + test("Verify that the publish event with 'CANCELLED' status throws error", async ({ + request, + }) => { + const domainId = randomUUID(); + logger.info(`Testing event subscription with domainId: ${domainId}`); + const preparedEvent = createPreparedV1Event({ + domainId, + status: "CANCELLED", + }); + const response = await sendSnsEvent(preparedEvent); + + expect(response.MessageId).toBeTruthy(); + + // poll supplier allocator to check if supplier has been allocated + const message = await pollSupplierAllocatorLogForResolvedSpec(domainId); + const supplierAllocatorLog = JSON.parse(message) as { + msg?: { supplierSpec?: { supplierId?: string } }; + }; + const supplierId = supplierAllocatorLog.msg?.supplierSpec?.supplierId; + + logger.info( + `Supplier ${supplierId} allocated for domainId ${domainId} in supplier allocator lambda`, + ); + if (!supplierId) { + throw new Error("supplierId was not found in supplier allocator log"); + } + + const headers = createValidRequestHeaders(supplierId); + + const getLetterResponse = await request.get( + `${baseUrl}/${SUPPLIER_LETTERS}/${domainId}`, + { + headers, + }, + ); + + expect(getLetterResponse.status()).toBe(500); + await pollUpsertLetterLogForError( + "Message did not match an expected schema", + domainId, + ); + }); + + test("Verify that the duplicate event throws an error", async () => { + const domainId = randomUUID(); + logger.info(`Testing event subscription with domainId: ${domainId}`); + const preparedEvent = createPreparedV1Event({ + domainId, + status: "PREPARED", + }); + const response = await sendSnsEvent(preparedEvent); + + expect(response.MessageId).toBeTruthy(); + + // poll supplier allocator to check if supplier has been allocated + const message = await pollSupplierAllocatorLogForResolvedSpec(domainId); + const supplierAllocatorLog = JSON.parse(message) as { + msg?: { supplierSpec?: { supplierId?: string } }; + }; + const supplierId = supplierAllocatorLog.msg?.supplierSpec?.supplierId; + + logger.info( + `Supplier ${supplierId} allocated for domainId ${domainId} in supplier allocator lambda`, + ); + if (!supplierId) { + throw new Error("supplierId was not found in supplier allocator log"); + } + + // send same event again to simulate duplicate event + const duplicateResponse = await sendSnsEvent(preparedEvent); + expect(duplicateResponse.MessageId).toBeTruthy(); + + // poll supplier upsert to check if duplicate event was processed + await pollUpsertLetterLogForError( + `Letter with id ${domainId} already exists for supplier ${supplierId}"`, + ); + }); +}); diff --git a/tests/config/global-setup.ts b/tests/config/global-setup.ts index 3586eff00..ed752463a 100644 --- a/tests/config/global-setup.ts +++ b/tests/config/global-setup.ts @@ -1,9 +1,6 @@ import { logger } from "tests/helpers/pino-logger"; -import { - checkSupplierExists, - createSupplierEntry, - createTestData, -} from "../helpers/generate-fetch-test-data"; +import { supplierDataSetup } from "tests/helpers/suppliers-setup-helper"; +import { createTestData } from "../helpers/generate-fetch-test-data"; import { SUPPLIERID } from "../constants/api-constants"; async function globalSetup() { @@ -16,17 +13,7 @@ async function globalSetup() { await createTestData(SUPPLIERID, 10); // check supplier exists - const supplier = await checkSupplierExists(SUPPLIERID); - if (supplier) { - logger.info(`Supplier with ID ${SUPPLIERID} already exists.`); - logger.info(""); - logger.info("*** GLOBAL SETUP COMPLETE ***"); - logger.info(""); - return; - } - - logger.info(`Creating supplier entry with ID: ${SUPPLIERID}`); - await createSupplierEntry(SUPPLIERID); + await supplierDataSetup(SUPPLIERID); logger.info(""); logger.info("*** GLOBAL SETUP COMPLETE ***"); diff --git a/tests/config/main.config.ts b/tests/config/main.config.ts index 309386432..8034eed82 100644 --- a/tests/config/main.config.ts +++ b/tests/config/main.config.ts @@ -10,10 +10,16 @@ const localConfig: PlaywrightTestConfig = { reporter: getReporters("api-test"), projects: [ { - name: "component-tests", - testDir: path.resolve(__dirname, "../component-tests"), + name: "apiGateway-tests", + testDir: path.resolve(__dirname, "../component-tests/apiGateway-tests"), testMatch: "**/*.spec.ts", }, + { + name: "events-tests", + testDir: path.resolve(__dirname, "../component-tests/events-tests"), + testMatch: "**/*.spec.ts", + dependencies: ["apiGateway-tests"], + }, ], }; diff --git a/tests/constants/api-constants.ts b/tests/constants/api-constants.ts index d453efb3d..bcded0a19 100644 --- a/tests/constants/api-constants.ts +++ b/tests/constants/api-constants.ts @@ -10,3 +10,8 @@ export const MI_ENDPOINT = "mi"; export const SUPPLIERTABLENAME = `nhs-${envName}-supapi-suppliers`; export const UPSERT_LETTER_LAMBDA_ARN = `arn:aws:lambda:eu-west-2:820178564574:function:nhs-${envName}-supapi-upsertletter`; export const DATA = "data"; +export const EVENT_SUBSCRIPTION_TOPIC_NAME = `nhs-${envName}-supapi-eventsub`; +export const AWS_ACCOUNT_ID = process.env.AWS_ACCOUNT_ID ?? "820178564574"; +export const EVENT_SUBSCRIPTION_TOPIC_ARN = + process.env.EVENT_SUBSCRIPTION_TOPIC_ARN ?? + `arn:aws:sns:${AWS_REGION}:${AWS_ACCOUNT_ID}:${EVENT_SUBSCRIPTION_TOPIC_NAME}`; diff --git a/tests/constants/request-headers.ts b/tests/constants/request-headers.ts index 6e113e2ad..3dd184bd4 100644 --- a/tests/constants/request-headers.ts +++ b/tests/constants/request-headers.ts @@ -42,10 +42,10 @@ export function createHeaderWithNoCorrelationId(): RequestHeaders { return requestHeaders; } -export function createValidRequestHeaders(): RequestHeaders { +export function createValidRequestHeaders(supplierId?: string): RequestHeaders { let requestHeaders: RequestHeaders; requestHeaders = { - "NHSD-Supplier-ID": SUPPLIERID, + "NHSD-Supplier-ID": supplierId || SUPPLIERID, "NHSD-Correlation-ID": "12345", "X-Request-ID": "requestId1", }; diff --git a/tests/helpers/aws-cloudwatch-helper.ts b/tests/helpers/aws-cloudwatch-helper.ts new file mode 100644 index 000000000..884974c2c --- /dev/null +++ b/tests/helpers/aws-cloudwatch-helper.ts @@ -0,0 +1,101 @@ +import { + CloudWatchLogsClient, + FilterLogEventsCommand, +} from "@aws-sdk/client-cloudwatch-logs"; +import { AWS_REGION, envName } from "tests/constants/api-constants"; + +const sleep = (ms: number) => + new Promise((resolve) => { + setTimeout(resolve, ms); + }); + +export async function pollSupplierAllocatorLogForResolvedSpec( + domainId?: string, +): Promise { + const intervalMs = 5000; + const startTimeMs = Date.now() - 5 * 60_000; + const timeoutMs = 120_000; + + const client = new CloudWatchLogsClient({ region: AWS_REGION }); + const logGroupName = `/aws/lambda/nhs-${envName}-supapi-supplier-allocator`; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const response = await client.send( + new FilterLogEventsCommand({ + logGroupName, + startTime: startTimeMs, + interleaved: true, + limit: 100, + filterPattern: domainId + ? `"Sending message to upsert letter queue" "${domainId}"` + : `"Sending message to upsert letter queue"`, + }), + ); + + const foundEvent = (response.events ?? []).find((event) => { + const message = event.message ?? ""; + return ( + message.includes( + '"description":"Sending message to upsert letter queue"', + ) && + (!domainId || message.includes(domainId)) + ); + }); + if (foundEvent?.message) { + return foundEvent.message; + } + + await sleep(intervalMs); + } + + throw new Error( + `Timed out waiting for resolved supplier spec log in ${logGroupName}`, + ); +} + +export async function pollUpsertLetterLogForError( + msgToCheck: string, + domainId?: string, +): Promise { + const intervalMs = 5000; + const startTimeMs = Date.now() - 5 * 60_000; + const timeoutMs = 120_000; + + const client = new CloudWatchLogsClient({ region: AWS_REGION }); + const logGroupName = `/aws/lambda/nhs-${envName}-supapi-upsertletter`; + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + const response = await client.send( + new FilterLogEventsCommand({ + logGroupName, + startTime: startTimeMs, + interleaved: true, + limit: 100, + filterPattern: domainId + ? `"Error processing upsert of record" "${domainId}"` + : `"Error processing upsert of record"`, + }), + ); + + const foundEvent = (response.events ?? []).find((event) => { + const message = event.message ?? ""; + return ( + message.includes('"description":"Error processing upsert of record"') && + (message.includes(`"message":"${msgToCheck}`) || + message.includes(`"message": "${msgToCheck}`)) + ); + }); + + if (foundEvent?.message) { + return foundEvent.message; + } + + await sleep(intervalMs); + } + + throw new Error( + `Timed out waiting for upsert letter error log in ${logGroupName}`, + ); +} diff --git a/tests/helpers/event-fixtures.ts b/tests/helpers/event-fixtures.ts new file mode 100644 index 000000000..621daaffc --- /dev/null +++ b/tests/helpers/event-fixtures.ts @@ -0,0 +1,38 @@ +export function createPreparedV1Event(overrides: Record = {}) { + const now = new Date().toISOString(); + + return { + specversion: "1.0", + id: (overrides.id as string) ?? "7b9a03ca-342a-4150-b56b-989109c45613", + source: "/data-plane/letter-rendering/test", + subject: "client/client1/letter-request/letterRequest1", + type: "uk.nhs.notify.letter-rendering.letter-request.prepared.v1", + time: now, + dataschema: + "https://notify.nhs.uk/cloudevents/schemas/letter-rendering/letter-request.prepared.1.0.0.schema.json", + dataschemaversion: "1.0.0", + data: { + domainId: + (overrides.domainId as string) ?? + "fe658e11-0ffc-44f4-8ad6-0fafe75bfeee", + letterVariantId: "digitrials-aspiring", + requestId: "request1", + requestItemId: "requestItem1", + requestItemPlanId: "requestItemPlan1", + clientId: "client1", + campaignId: "campaign1", + templateId: "template1", + url: (overrides.url as string) ?? "s3://letterDataBucket/letter1.pdf", + sha256Hash: + "3a7bd3e2360a3d29eea436fcfb7e44c735d117c8f2f1d2d1e4f6e8f7e6e8f7e6", + createdAt: now, + pageCount: 1, + status: (overrides.status as string) ?? "PREPARED", + }, + traceparent: "00-0af7651916cd43dd8448eb211c803191-b7ad6b7169203331-01", + recordedtime: now, + severitynumber: 2, + severitytext: "INFO", + plane: "data", + }; +} diff --git a/tests/helpers/send-sns-event.ts b/tests/helpers/send-sns-event.ts new file mode 100644 index 000000000..0990bf0a5 --- /dev/null +++ b/tests/helpers/send-sns-event.ts @@ -0,0 +1,22 @@ +import { + MessageAttributeValue, + PublishCommand, + PublishCommandOutput, +} from "@aws-sdk/client-sns"; +import { EVENT_SUBSCRIPTION_TOPIC_ARN } from "tests/constants/api-constants"; +import { snsClient } from "tests/helpers/aws-sns-helper"; + +export type SnsEventMessage = Record | string; + +export async function sendSnsEvent( + message: SnsEventMessage, + messageAttributes?: Record, +): Promise { + return snsClient.send( + new PublishCommand({ + TopicArn: EVENT_SUBSCRIPTION_TOPIC_ARN, + Message: typeof message === "string" ? message : JSON.stringify(message), + ...(messageAttributes ? { MessageAttributes: messageAttributes } : {}), + }), + ); +} diff --git a/tests/helpers/suppliers-setup-helper.ts b/tests/helpers/suppliers-setup-helper.ts new file mode 100644 index 000000000..f338ae7e9 --- /dev/null +++ b/tests/helpers/suppliers-setup-helper.ts @@ -0,0 +1,16 @@ +import { logger } from "tests/helpers/pino-logger"; +import { + checkSupplierExists, + createSupplierEntry, +} from "./generate-fetch-test-data"; + +export async function supplierDataSetup(supplierId: string) { + const supplier = await checkSupplierExists(supplierId); + if (supplier) { + logger.info(`Supplier with ID ${supplierId} already exists.`); + return; + } + + logger.info(`Creating supplier entry with ID: ${supplierId}`); + await createSupplierEntry(supplierId); +} diff --git a/tests/package.json b/tests/package.json index 6f0d4d45e..36f99c0cb 100644 --- a/tests/package.json +++ b/tests/package.json @@ -28,6 +28,9 @@ "zod": "^4.1.11" }, "description": "", + "devDependencies": { + "@aws-sdk/client-cloudwatch-logs": "^3.1003.0" + }, "keywords": [], "license": "ISC", "main": "index.js", @@ -40,7 +43,7 @@ "clean": "rimraf $(pwd)/target", "lint": "eslint .", "lint:fix": "eslint . --fix", - "test:component": "playwright test --config=config/main.config.ts --max-failures=10 --project=component-tests", + "test:component": "playwright test --config=config/main.config.ts --max-failures=10", "test:pact": "./pact-tests/run-pact-tests.sh", "test:performance": "playwright test --config=config/performance/performance.config.ts --max-failures=10 --project=performance", "test:sandbox": "playwright test --config=config/sandbox.config.ts --max-failures=10 --project=sandbox", diff --git a/tests/sandbox/create-mi-sandbox.spec.ts b/tests/sandbox/create-mi-sandbox.spec.ts new file mode 100644 index 000000000..e6d456dc4 --- /dev/null +++ b/tests/sandbox/create-mi-sandbox.spec.ts @@ -0,0 +1,29 @@ +import { expect, test } from "@playwright/test"; +import { + MI_ENDPOINT, + SUPPLIER_API_URL_SANDBOX, +} from "tests/constants/api-constants"; +import { apiSandboxCreateMiTestData } from "./testCases/create-mi-test-cases"; + +test.describe("Sandbox Tests To Verify Mi Endpoint", () => { + for (const { + body, + expectedResponse, + expectedStatus, + header, + testCase, + } of apiSandboxCreateMiTestData) { + test(`Post /Mi endpoint returns ${testCase}`, async ({ request }) => { + const response = await request.post( + `${SUPPLIER_API_URL_SANDBOX}/${MI_ENDPOINT}`, + { + headers: header, + data: body, + }, + ); + expect(response.status()).toBe(expectedStatus); + const responseBody = await response.json(); + expect(responseBody).toMatchObject(expectedResponse); + }); + } +}); diff --git a/tests/sandbox/get-letter-data-sandbox.spec.ts b/tests/sandbox/get-letter-data-sandbox.spec.ts new file mode 100644 index 000000000..c001c61a7 --- /dev/null +++ b/tests/sandbox/get-letter-data-sandbox.spec.ts @@ -0,0 +1,57 @@ +import { expect, test } from "@playwright/test"; +import { + SUPPLIER_API_URL_SANDBOX, + SUPPLIER_LETTERS, +} from "../constants/api-constants"; +import { + RequestSandBoxHeaders, + sandBoxHeader, +} from "../constants/request-headers"; + +test.describe("Sandbox Tests To Get Letter Data", () => { + test(`Get Letter Data endpoint returns 200 for valid id`, async ({ + request, + }) => { + const id = "2AL5eYSWGzCHlGmzNxuqVusPxDg"; + const headers: RequestSandBoxHeaders = sandBoxHeader; + const response = await request.get( + `${SUPPLIER_API_URL_SANDBOX}/${SUPPLIER_LETTERS}/${id}/data`, + { + headers, + }, + ); + + expect(response.status()).toBe(200); + expect(response.headers()["content-type"]).toMatch("application/pdf"); + }); + test(`Get Letter Data endpoint returns 404 for invalid id`, async ({ + request, + }) => { + const id = "invalid-id"; + const headers: RequestSandBoxHeaders = sandBoxHeader; + const response = await request.get( + `${SUPPLIER_API_URL_SANDBOX}/${SUPPLIER_LETTERS}/${id}/data`, + { + headers, + }, + ); + + expect(response.status()).toBe(404); + const responseBody = await response.json(); + expect(responseBody).toMatchObject({ + errors: [ + { + code: "NOTIFY_RESOURCE_NOT_FOUND", + detail: "No resource found with that ID", + id: expect.any(String), + links: { + about: + "https://digital.nhs.uk/developer/api-catalogue/nhs-notify-supplier", + }, + status: "404", + title: "Resource not found", + }, + ], + }); + }); +}); diff --git a/tests/sandbox/get-letter-status.spec.ts b/tests/sandbox/get-letter-status-sandbox.spec.ts similarity index 100% rename from tests/sandbox/get-letter-status.spec.ts rename to tests/sandbox/get-letter-status-sandbox.spec.ts diff --git a/tests/sandbox/get-list-of-letters.spec.ts b/tests/sandbox/get-list-of-letters-sandbox.spec.ts similarity index 100% rename from tests/sandbox/get-list-of-letters.spec.ts rename to tests/sandbox/get-list-of-letters-sandbox.spec.ts diff --git a/tests/sandbox/testCases/create-mi-test-cases.ts b/tests/sandbox/testCases/create-mi-test-cases.ts new file mode 100644 index 000000000..31666a0f2 --- /dev/null +++ b/tests/sandbox/testCases/create-mi-test-cases.ts @@ -0,0 +1,139 @@ +import { + RequestSandBoxHeaders, + sandBoxHeader, +} from "../../constants/request-headers"; +import { ErrorMessageBody } from "../../helpers/common-types"; +import { + NoRequestIdHeaders, + SandboxErrorResponse, +} from "./get-list-of-letters-test-cases"; + +type ApiSandboxMiRequestTestCase = { + testCase: string; + header?: RequestSandBoxHeaders | NoRequestIdHeaders; + body: MiRequestBody; + expectedResponse: MiRequestBody | ErrorMessageBody | SandboxErrorResponse; + expectedStatus: number; +}; + +export type MiRequestBody = { + data: { + type: string; + attributes: { + groupId: string; + lineItem: string; + quantity: number; + specificationId: string; + stockRemaining: number; + timestamp: string; + }; + }; +}; + +export const apiSandboxCreateMiTestData: ApiSandboxMiRequestTestCase[] = [ + { + testCase: "200 when a valid request is passed", + header: sandBoxHeader, + body: miValidSandboxRequest(), + expectedStatus: 200, + expectedResponse: { + data: { + attributes: { + groupId: "abc123", + lineItem: "envelope-business-standard", + quantity: 22, + specificationId: "2WL5eYSWGzCHlGmzNxuqVusPxDg", + stockRemaining: 2000, + timestamp: "2023-11-17T14:27:51.413Z", + }, + type: "ManagementInformation", + }, + }, + }, + { + testCase: "400 when a invalid timestamp is passed", + header: sandBoxHeader, + body: miInvalidDateSandboxRequest(), + expectedStatus: 400, + expectedResponse: { + message: + 'request.body.data.attributes.timestamp should match format "date-time"', + errors: [ + { + path: ".body.data.attributes.timestamp", + message: 'should match format "date-time"', + errorCode: "format.openapi.validation", + }, + ], + }, + }, + { + testCase: "404 when a invalid id is passed", + header: sandBoxHeader, + body: miInvalidSandboxRequest(), + expectedStatus: 404, + expectedResponse: { + errors: [ + { + id: "rrt-1931948104716186917-c-geu2-10664-3111479-3.0", + code: "NOTIFY_RESOURCE_NOT_FOUND", + links: { + about: + "https://digital.nhs.uk/developer/api-catalogue/nhs-notify-supplier", + }, + status: "404", + title: "Resource not found", + detail: "No resource found with that ID", + }, + ], + }, + }, +]; + +export function miValidSandboxRequest(): MiRequestBody { + return { + data: { + attributes: { + groupId: "abc123", + lineItem: "envelope-business-standard", + quantity: 22, + specificationId: "2WL5eYSWGzCHlGmzNxuqVusPxDg", + stockRemaining: 2000, + timestamp: "2023-11-17T14:27:51.413Z", + }, + type: "ManagementInformation", + }, + }; +} + +export function miInvalidSandboxRequest(): MiRequestBody { + return { + data: { + attributes: { + groupId: "abc123", + lineItem: "envelope-business-standard", + quantity: 22, + specificationId: "", + stockRemaining: 2000, + timestamp: "2023-11-17T14:27:51.413Z", + }, + type: "ManagementInformation", + }, + }; +} + +export function miInvalidDateSandboxRequest(): MiRequestBody { + return { + data: { + attributes: { + groupId: "abc123", + lineItem: "envelope-business-standard", + quantity: 22, + specificationId: "2WL5eYSWGzCHlGmzNxuqVusPxDg", + stockRemaining: 2000, + timestamp: "yesterday", + }, + type: "ManagementInformation", + }, + }; +} diff --git a/tests/sandbox/testCases/get-letter-status-test-cases.ts b/tests/sandbox/testCases/get-letter-status-test-cases.ts index 36c9c3279..1e039b922 100644 --- a/tests/sandbox/testCases/get-letter-status-test-cases.ts +++ b/tests/sandbox/testCases/get-letter-status-test-cases.ts @@ -120,7 +120,7 @@ export const apiSandboxGetLetterStatusTestData: ApiSandboxGetLetterStatusTestCas testCase: "404 response when no record is found for the given id", id: "24L5eYSWGzCHlGmzNxuqVusP", header: sandBoxHeader, - expectedStatus: 200, + expectedStatus: 404, expectedResponse: { errors: [ { diff --git a/tests/sandbox/testCases/update-multiple-status-test-cases.ts b/tests/sandbox/testCases/update-multiple-status-test-cases.ts index 843193ee2..96f526829 100644 --- a/tests/sandbox/testCases/update-multiple-status-test-cases.ts +++ b/tests/sandbox/testCases/update-multiple-status-test-cases.ts @@ -14,7 +14,7 @@ export type ApiSandboxUpdateLetterStatusTestData = { export const apiSandboxMultipleLetterStatusTestData: ApiSandboxUpdateLetterStatusTestData[] = [ { - testCase: "200 response if records are updated", + testCase: "202 response if records are updated", header: sandBoxHeader, body: { data: [ @@ -98,7 +98,7 @@ export const apiSandboxMultipleLetterStatusTestData: ApiSandboxUpdateLetterStatu }, ], }, - expectedStatus: 200, + expectedStatus: 202, }, { testCase: "404 response if invalid request is passed", diff --git a/tests/sandbox/update-letter-status.spec.ts b/tests/sandbox/update-letter-status-sandbox.spec.ts similarity index 83% rename from tests/sandbox/update-letter-status.spec.ts rename to tests/sandbox/update-letter-status-sandbox.spec.ts index dcd724fd9..595058b13 100644 --- a/tests/sandbox/update-letter-status.spec.ts +++ b/tests/sandbox/update-letter-status-sandbox.spec.ts @@ -23,9 +23,11 @@ test.describe("Sandbox Tests To Update Letter Status", () => { }, ); - const res = await response.json(); expect(response.status()).toBe(expectedStatus); - expect(res).toEqual(expectedResponse); + if (response.status() !== 202) { + const responseBody = await response.json(); + expect(responseBody).toEqual(expectedResponse); + } }); } }); diff --git a/tests/sandbox/update-multiple-letter-status.spec.ts b/tests/sandbox/update-multiple-letter-status-sandbox.spec.ts similarity index 100% rename from tests/sandbox/update-multiple-letter-status.spec.ts rename to tests/sandbox/update-multiple-letter-status-sandbox.spec.ts