diff --git a/frontend/src/__tests__/app/message-plans/preview-message-plan/__snapshots__/page.test.tsx.snap b/frontend/src/__tests__/app/message-plans/preview-message-plan/__snapshots__/page.test.tsx.snap index 7faa73e59..8595e8c2f 100644 --- a/frontend/src/__tests__/app/message-plans/preview-message-plan/__snapshots__/page.test.tsx.snap +++ b/frontend/src/__tests__/app/message-plans/preview-message-plan/__snapshots__/page.test.tsx.snap @@ -713,7 +713,9 @@ exports[`full cascade plan matches snapshot 1`] = ` > Large print letter (optional) -

+

@@ -741,6 +743,7 @@ exports[`full cascade plan matches snapshot 1`] = `

Large print letter (optional) -

+

@@ -692,6 +694,7 @@ exports[`Review and move to production page matches snapshot for full cascade 1`

-

+

{template.name} @@ -179,6 +179,7 @@ export function MessagePlanCascadePreview({

[cognito\_user\_pool\_id](#output\_cognito\_user\_pool\_id) | n/a | | [deployment](#output\_deployment) | Deployment details used for post-deployment scripts | | [download\_bucket\_name](#output\_download\_bucket\_name) | n/a | -| [event\_cache\_bucket\_name](#output\_event\_cache\_bucket\_name) | n/a | +| [events\_sns\_topic\_arn](#output\_events\_sns\_topic\_arn) | n/a | | [internal\_bucket\_name](#output\_internal\_bucket\_name) | n/a | | [quarantine\_bucket\_name](#output\_quarantine\_bucket\_name) | n/a | | [request\_proof\_queue\_url](#output\_request\_proof\_queue\_url) | n/a | diff --git a/infrastructure/terraform/components/sandbox/module_eventpub.tf b/infrastructure/terraform/components/sandbox/module_eventpub.tf index a758a29aa..a0a93d04a 100644 --- a/infrastructure/terraform/components/sandbox/module_eventpub.tf +++ b/infrastructure/terraform/components/sandbox/module_eventpub.tf @@ -17,7 +17,6 @@ module "eventpub" { enable_event_cache = true enable_sns_delivery_logging = false enable_firehose_raw_message_delivery = true - event_cache_buffer_interval = 0 sns_success_logging_sample_percent = 0 force_destroy = true diff --git a/infrastructure/terraform/components/sandbox/outputs.tf b/infrastructure/terraform/components/sandbox/outputs.tf index 5288979a5..cab82d420 100644 --- a/infrastructure/terraform/components/sandbox/outputs.tf +++ b/infrastructure/terraform/components/sandbox/outputs.tf @@ -66,10 +66,10 @@ output "test_email_bucket_prefix" { value = "emails-${var.environment}" } -output "event_cache_bucket_name" { - value = module.eventpub.s3_bucket_event_cache.bucket -} - output "routing_config_table_name" { value = module.backend_api.routing_config_table_name } + +output "events_sns_topic_arn" { + value = module.eventpub.sns_topic.arn +} diff --git a/package-lock.json b/package-lock.json index b426ccbc7..3fa608833 100644 --- a/package-lock.json +++ b/package-lock.json @@ -27837,6 +27837,7 @@ "@aws-sdk/client-eventbridge": "3.911.0", "@aws-sdk/client-lambda": "3.911.0", "@aws-sdk/client-s3": "3.911.0", + "@aws-sdk/client-sns": "^3.911.0", "@aws-sdk/client-sqs": "3.911.0", "@aws-sdk/client-ssm": "3.911.0", "@aws-sdk/lib-dynamodb": "3.911.0", @@ -28064,6 +28065,15 @@ "node": ">=18.0.0" } }, + "tests/test-team/node_modules/zod": { + "version": "4.0.17", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.0.17.tgz", + "integrity": "sha512-1PHjlYRevNxxdy2JZ8JcNAw7rX8V9P1AKkP+x/xZfxB0K5FYfuV+Ug6P/6NVSR2jHQ+FzDDoDHS04nYUsOIyLQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, "utils/backend-config": { "name": "nhs-notify-web-template-management-util-backend-config", "version": "0.0.1", diff --git a/tests/test-team/config/event/event.setup.ts b/tests/test-team/config/event/event.setup.ts index 64294b976..1d3b9a0ca 100644 --- a/tests/test-team/config/event/event.setup.ts +++ b/tests/test-team/config/event/event.setup.ts @@ -2,6 +2,7 @@ import path from 'node:path'; import { test as setup } from '@playwright/test'; import { BackendConfigHelper } from 'nhs-notify-web-template-management-util-backend-config'; import { createAuthHelper } from '../../helpers/auth/cognito-auth-helper'; +import { EventSubscriber } from '../../helpers/events/event-subscriber'; setup('event test setup', async () => { const backendConfig = BackendConfigHelper.fromTerraformOutputsFile( @@ -11,4 +12,10 @@ setup('event test setup', async () => { BackendConfigHelper.toEnv(backendConfig); await createAuthHelper().setup(); + + await EventSubscriber.cleanup( + 'event', + backendConfig.environment, + backendConfig.eventsSnsTopicArn + ); }); diff --git a/tests/test-team/fixtures/template-management-event-subscriber.ts b/tests/test-team/fixtures/template-management-event-subscriber.ts new file mode 100644 index 000000000..20ceedbe9 --- /dev/null +++ b/tests/test-team/fixtures/template-management-event-subscriber.ts @@ -0,0 +1,35 @@ +import { test as base } from '@playwright/test'; +import { EventSubscriber } from '../helpers/events/event-subscriber'; + +type EventSubscriberFixture = { + eventSubscriber: EventSubscriber; +}; + +export const templateManagementEventSubscriber = base.extend< + object, + EventSubscriberFixture +>({ + eventSubscriber: [ + // eslint-disable-next-line no-empty-pattern + async ({}, use, workerInfo) => { + const eventSource = `//notify.nhs.uk/sbx/nhs-notify-template-management-dev/${process.env.ENVIRONMENT}`; + + const subscriber = new EventSubscriber( + process.env.EVENTS_SNS_TOPIC_ARN, + process.env.AWS_ACCOUNT_ID, + process.env.ENVIRONMENT, + 'event', + 'config-publishing', + eventSource, + workerInfo.workerIndex + ); + + await subscriber.initialise(); + + await use(subscriber).finally(() => subscriber.teardown()); + }, + { scope: 'worker' }, + ], +}); + +export const { expect } = templateManagementEventSubscriber; diff --git a/tests/test-team/global.d.ts b/tests/test-team/global.d.ts index bbfd7c3eb..6654bfe88 100644 --- a/tests/test-team/global.d.ts +++ b/tests/test-team/global.d.ts @@ -2,10 +2,12 @@ declare global { namespace NodeJS { interface ProcessEnv { API_BASE_URL: string; + AWS_ACCOUNT_ID: string; CLIENT_SSM_PATH_PREFIX: string; COGNITO_USER_POOL_CLIENT_ID: string; COGNITO_USER_POOL_ID: string; - EVENT_CACHE_BUCKET_NAME: string; + ENVIRONMENT: string; + EVENTS_SNS_TOPIC_ARN: string; PLAYWRIGHT_RUN_ID: string; REQUEST_PROOF_QUEUE_URL: string; ROUTING_CONFIG_TABLE_NAME: string; diff --git a/tests/test-team/helpers/events/event-cache-helper.ts b/tests/test-team/helpers/events/event-cache-helper.ts deleted file mode 100644 index e517a53f6..000000000 --- a/tests/test-team/helpers/events/event-cache-helper.ts +++ /dev/null @@ -1,141 +0,0 @@ -import { z } from 'zod'; -import { SelectObjectContentEventStream } from '@aws-sdk/client-s3'; -import { - $RoutingConfigCompletedEventV1, - $RoutingConfigDeletedEventV1, - $RoutingConfigDraftedEventV1, - $TemplateCompletedEventV1, - $TemplateDeletedEventV1, - $TemplateDraftedEventV1, -} from '@nhsdigital/nhs-notify-event-schemas-template-management'; -import { - differenceInSeconds, - addHours, - startOfHour, - endOfHour, -} from 'date-fns'; -import { S3Helper } from '../s3-helper'; - -const $NHSNotifyEvent = z.discriminatedUnion('type', [ - $TemplateCompletedEventV1, - $TemplateDraftedEventV1, - $TemplateDeletedEventV1, - $RoutingConfigCompletedEventV1, - $RoutingConfigDraftedEventV1, - $RoutingConfigDeletedEventV1, -]); - -type NHSNotifyEvent = z.infer; - -export class EventCacheHelper { - private readonly bucketName = process.env.EVENT_CACHE_BUCKET_NAME; - - async findEvents(from: Date, ids: string[]): Promise { - if (ids.length === 0) { - return []; - } - - const files = await Promise.all( - this.buildFilePaths(from).map((path) => { - return S3Helper.listAll(this.bucketName, path); - }) - ); - - const filteredFiles = S3Helper.filterAndSort(files.flat(), from); - - const eventPromises = filteredFiles.map((file) => - this.queryFileForEvents(file.Key!, ids) - ); - - const results = await Promise.all(eventPromises); - - return results.flat(); - } - - private async queryFileForEvents( - fileName: string, - ids: string[] - ): Promise { - const formattedIds = ids.map((r) => `'${r}'`); - - const response = await S3Helper.queryJSONLFile( - this.bucketName, - fileName, - `SELECT * FROM S3Object s WHERE s.data.id IN (${formattedIds})` - ); - - if (!response.Payload) { - return []; - } - - return await this.parse(fileName, response.Payload); - } - - private async parse( - fileName: string, - payload: AsyncIterable - ): Promise { - const events: NHSNotifyEvent[] = []; - - for await (const event of payload) { - if (!event.Records?.Payload) continue; - - const chunk = Buffer.from(event.Records.Payload).toString('utf8'); - - const parsedEvents = chunk - .split('\n') - .filter((line) => line.trim()) - .map((line) => { - const { data, success, error } = $NHSNotifyEvent.safeParse( - JSON.parse(line) - ); - - if (success) { - return data; - } - - throw new Error( - `Unrecognized event schema detected in S3 file: ${fileName}`, - { - cause: { error }, - } - ); - }); - - events.push(...parsedEvents); - } - - return events; - } - - /* - * Get files paths for the current hour - * and next hour if the difference in seconds is greater than toleranceInSeconds - * - * The way firehose stores files is yyyy/mm/dd/hh. - * On a boundary of 15:59:58 you'll find files in both 15 and 16 hour folders - */ - private buildFilePaths(start: Date, toleranceInSeconds = 30): string[] { - const paths = [this.buildPathPrefix(start)]; - - const end = addHours(startOfHour(start), 1); - - const difference = differenceInSeconds(endOfHour(start), start, { - roundingMethod: 'ceil', - }); - - if (toleranceInSeconds >= difference) { - paths.push(this.buildPathPrefix(end)); - } - - return paths; - } - - private buildPathPrefix(date: Date): string { - return date - .toISOString() - .slice(0, 13) - .replace('T', '/') - .replaceAll('-', '/'); - } -} diff --git a/tests/test-team/helpers/events/event-subscriber.ts b/tests/test-team/helpers/events/event-subscriber.ts new file mode 100644 index 000000000..229c77361 --- /dev/null +++ b/tests/test-team/helpers/events/event-subscriber.ts @@ -0,0 +1,343 @@ +import { + CreateQueueCommand, + DeleteMessageBatchCommand, + DeleteQueueCommand, + ListQueuesCommand, + Message, + ReceiveMessageCommand, + SQSClient, +} from '@aws-sdk/client-sqs'; +import { setTimeout } from 'node:timers/promises'; +import { + ListSubscriptionsByTopicCommand, + SNSClient, + SubscribeCommand, + UnsubscribeCommand, +} from '@aws-sdk/client-sns'; +import { ZodType } from 'zod'; + +type Event = { + time: Date; + record: T extends undefined ? Record : T; +}; + +/* + Class instances must be created as worker-scoped playwright fixtures. + Each worker owns its queue and subscription and runs tests in serial internally. + Each fixture should only be used in a single suite. + The cleanup static method should be called in a suite's global setup. + + This util assumes that the event JSON has a unique 'id' property. Use of + non-unique IDs will lead to non-deterministic behaviour in tests. + + Receive can be called repeatedly to poll for new events. The same value for + 'since' should be used across polls since this is used to trim cached messages. + + Since by default tests can run in parallel, events triggered by other tests + may be received. Filtering should be applied or if necessary tests should be run serially. +*/ + +export class EventSubscriber { + static readonly sns = new SNSClient({ region: 'eu-west-2' }); + + static readonly sqs = new SQSClient({ region: 'eu-west-2' }); + + static readonly rootNamespace = 'tm-e2e-es'; + + private readonly queueName: string; + + private readonly queueArn: string; + + private readonly queueUrl: string; + + private subscriptionArn: string | undefined = undefined; + + private messages = new Map< + string, + { record: Record; time: Date } + >(); + + constructor( + private readonly topic: string, + private readonly account: string, + private readonly environment: string, + private readonly suite: string, + private readonly tag: string, + private readonly eventSource: string | string[], + private readonly workerIndex: number + ) { + this.queueName = `${EventSubscriber.rootNamespace}-${this.environment}-${this.suite}-${this.tag}-${this.workerIndex}`; + this.queueArn = `arn:aws:sqs:eu-west-2:${this.account}:${this.queueName}`; + this.queueUrl = `https://sqs.eu-west-2.amazonaws.com/${this.account}/${this.queueName}`; + } + + async initialise() { + await this.createQueue(); + + this.subscriptionArn = await this.createSubscription(); + } + + async receive({ + since, + match, + }: { + since: Date; + match?: ZodType; + }): Promise[]> { + this.trimCached(since); + + const received: Message[] = []; + + let polledCount = 0; + + do { + const { Messages: polled = [] } = await EventSubscriber.sqs.send( + new ReceiveMessageCommand({ + QueueUrl: this.queueUrl, + MaxNumberOfMessages: 10, + }) + ); + + polledCount = polled.length; + + if (polledCount) { + await EventSubscriber.sqs.send( + new DeleteMessageBatchCommand({ + QueueUrl: this.queueUrl, + Entries: polled.map((msg, index) => ({ + Id: index.toString(), + ReceiptHandle: msg.ReceiptHandle!, + })), + }) + ); + } + + received.push(...polled); + } while (polledCount > 0); + + const parsed = received.flatMap(({ Body }) => { + if (Body) { + const snsEvent = JSON.parse(Body); + + const record = JSON.parse(snsEvent.Message); + + const envelopeId = record.id; + + if (!envelopeId) { + throw new Error('Event record is missing id field'); + } + + const eventTime = record.time; + + if (!eventTime) { + throw new Error('Event record is missing time field'); + } + + const time = new Date(eventTime); + + return [{ time, record, envelopeId }]; + } + + return []; + }); + + for (const event of parsed) { + this.messages.set(event.envelopeId, { + record: event.record, + time: event.time, + }); + } + + const filtered = [...this.messages.values()].filter(({ time, record }) => { + if (since && time <= since) return false; + + if (match) { + return match.safeParse(record).success; + } + + return true; + }) as Event[]; + + return filtered.sort((a, b) => a.time.getTime() - b.time.getTime()); + } + + private trimCached(since: Date) { + for (const [id, { time }] of this.messages) { + if (time < since) { + this.messages.delete(id); + } + } + } + + async teardown() { + if (this.subscriptionArn) { + await EventSubscriber.deleteSubscription(this.subscriptionArn); + } + + await EventSubscriber.deleteQueue(this.queueUrl); + } + + private async createQueue() { + console.log(`Creating queue with ARN: ${this.queueArn}`); + + const policy = { + Version: '2012-10-17', + Statement: [ + { + Sid: 'AllowSnsSendMessage', + Effect: 'Allow', + Principal: { Service: 'sns.amazonaws.com' }, + Action: 'sqs:SendMessage', + Resource: this.queueArn, + Condition: { + ArnEquals: { + 'aws:SourceArn': this.topic, + }, + }, + }, + ], + }; + + let attempt = 0; + + while (attempt < 10) { + attempt += 1; + + try { + await EventSubscriber.sqs.send( + new CreateQueueCommand({ + QueueName: this.queueName, + Attributes: { + Policy: JSON.stringify(policy), + }, + }) + ); + break; + } catch (error) { + if ( + error instanceof Error && + // error instanceof QueueDeletedRecently does not work as expected + (error.name === 'QueueDeletedRecently' || + ('Code' in error && + error.Code === 'AWS.SimpleQueueService.QueueDeletedRecently')) + ) { + console.log( + `Queue deleted recently, retrying creation (attempt ${attempt})` + ); + await setTimeout(10_000); + } else { + throw error; + } + } + } + } + + private async createSubscription() { + console.log( + `Creating SNS subscription (topic: ${this.topic}) (queue: ${this.queueArn})` + ); + + const policy = + typeof this.eventSource === 'string' + ? `{ "source": ["${this.eventSource}"] }` + : `{ "source": ${JSON.stringify(this.eventSource)} }`; + + const subscription = await EventSubscriber.sns.send( + new SubscribeCommand({ + TopicArn: this.topic, + Protocol: 'sqs', + Endpoint: this.queueArn, + Attributes: { + FilterPolicyScope: 'MessageBody', + FilterPolicy: policy, + }, + }) + ); + + return subscription.SubscriptionArn; + } + + private static deleteQueue(url: string, warn = true) { + console.log(`Deleting queue with URL: ${url}`); + + return EventSubscriber.sqs + .send(new DeleteQueueCommand({ QueueUrl: url })) + .catch((error) => { + if (warn) { + console.warn(`Failed to delete queue at ${url}: ${error}`); + } + }); + } + + private static deleteSubscription(arn: string) { + console.log(`Deleting SNS subscription with ARN: ${arn}`); + + return EventSubscriber.sns + .send(new UnsubscribeCommand({ SubscriptionArn: arn })) + .catch((error) => { + console.warn(`Failed to delete subscription with ARN ${arn}: ${error}`); + }); + } + + static async cleanup(suite: string, environment: string, topicArn: string) { + const namePrefix = `${EventSubscriber.rootNamespace}-${environment}-${suite}-`; + + const urls: string[] = []; + + let nextQueuesToken: string | undefined; + + do { + const queues = await EventSubscriber.sqs.send( + new ListQueuesCommand({ + QueueNamePrefix: namePrefix, + NextToken: nextQueuesToken, + }) + ); + + nextQueuesToken = queues.NextToken; + + urls.push(...(queues.QueueUrls ?? [])); + } while (nextQueuesToken); + + const subscriptionArns: string[] = []; + + let nextSubscriptionsToken: string | undefined; + + const queueArns = new Set( + urls.map((url) => + url.replace( + /^https:\/\/sqs\.eu-west-2\.amazonaws\.com\/(\d+)\/([^/]+)$/, + 'arn:aws:sqs:eu-west-2:$1:$2' + ) + ) + ); + + do { + const subscriptions = await EventSubscriber.sns.send( + new ListSubscriptionsByTopicCommand({ + TopicArn: topicArn, + NextToken: nextSubscriptionsToken, + }) + ); + + nextSubscriptionsToken = subscriptions.NextToken; + + const queueSubscriptions = + subscriptions.Subscriptions?.flatMap(({ SubscriptionArn, Endpoint }) => + SubscriptionArn && Endpoint && queueArns.has(Endpoint) + ? [SubscriptionArn] + : [] + ) ?? []; + + subscriptionArns.push(...queueSubscriptions); + } while (nextSubscriptionsToken); + + for (const arn of subscriptionArns) { + await EventSubscriber.deleteSubscription(arn); + } + + for (const url of urls) { + await EventSubscriber.deleteQueue(url); + } + } +} diff --git a/tests/test-team/helpers/events/matchers.ts b/tests/test-team/helpers/events/matchers.ts new file mode 100644 index 000000000..6435d615c --- /dev/null +++ b/tests/test-team/helpers/events/matchers.ts @@ -0,0 +1,9 @@ +import z from 'zod'; + +const $PartialCloudEvent = z.object({ type: z.string() }); + +export const eventWithId = (id: string) => + $PartialCloudEvent.extend({ data: z.object({ id: z.literal(id) }) }); + +export const eventWithIdIn = (ids: [string, ...string[]]) => + $PartialCloudEvent.extend({ data: z.object({ id: z.enum(ids) }) }); diff --git a/tests/test-team/helpers/factories/template-factory.ts b/tests/test-team/helpers/factories/template-factory.ts index 23502cec7..123b46a6a 100644 --- a/tests/test-team/helpers/factories/template-factory.ts +++ b/tests/test-team/helpers/factories/template-factory.ts @@ -128,7 +128,6 @@ export const TemplateFactory = { owner: `CLIENT#${user.clientId}`, templateStatus, templateType: 'LETTER', - proofingEnabled: true, sidesCount: options?.sidesCount ?? 2, letterVariantId: options?.letterVariantId, }); diff --git a/tests/test-team/package.json b/tests/test-team/package.json index caabff168..23b1ce22b 100644 --- a/tests/test-team/package.json +++ b/tests/test-team/package.json @@ -5,6 +5,7 @@ "@aws-sdk/client-eventbridge": "3.911.0", "@aws-sdk/client-lambda": "3.911.0", "@aws-sdk/client-s3": "3.911.0", + "@aws-sdk/client-sns": "^3.911.0", "@aws-sdk/client-sqs": "3.911.0", "@aws-sdk/client-ssm": "3.911.0", "@aws-sdk/lib-dynamodb": "3.911.0", diff --git a/tests/test-team/pages/routing/choose-templates-page.ts b/tests/test-team/pages/routing/choose-templates-page.ts index 8ef4c3bb6..b6d602158 100644 --- a/tests/test-team/pages/routing/choose-templates-page.ts +++ b/tests/test-team/pages/routing/choose-templates-page.ts @@ -90,7 +90,11 @@ export class RoutingChooseTemplatesPage extends TemplateMgmtBasePage { public readonly email = this.messagePlanChannel('EMAIL'); - public readonly letter = this.messagePlanChannel('LETTER'); + public readonly letter = { + standard: this.messagePlanChannel('LETTER'), + largePrint: this.messagePlanChannel('x1'), + language: this.messagePlanChannel('foreign-language'), + }; public alternativeLetterFormats() { const conditionalTemplates = this.page.getByTestId( diff --git a/tests/test-team/pages/routing/email/index.ts b/tests/test-team/pages/routing/email/index.ts new file mode 100644 index 000000000..55103ae1c --- /dev/null +++ b/tests/test-team/pages/routing/email/index.ts @@ -0,0 +1,2 @@ +export * from './choose-email-template-page'; +export * from './preview-email-page'; diff --git a/tests/test-team/pages/routing/index.ts b/tests/test-team/pages/routing/index.ts index d5fab41f7..dcb0d38dc 100644 --- a/tests/test-team/pages/routing/index.ts +++ b/tests/test-team/pages/routing/index.ts @@ -2,6 +2,14 @@ export * from './campaign-id-required-page'; export * from './choose-message-order-page'; export * from './choose-templates-page'; export * from './create-message-plan-page'; -export * from './message-plans-page'; +export * from './edit-message-plan-settings-page'; +export * from './get-ready-to-move-page'; export * from './invalid-message-plan-page'; +export * from './message-plans-page'; +export * from './preview-message-plan-page'; export * from './review-and-move-to-production-page'; + +export * from './email'; +export * from './letter'; +export * from './nhs-app'; +export * from './sms'; diff --git a/tests/test-team/pages/routing/letter/index.ts b/tests/test-team/pages/routing/letter/index.ts new file mode 100644 index 000000000..d545ebf34 --- /dev/null +++ b/tests/test-team/pages/routing/letter/index.ts @@ -0,0 +1,6 @@ +export * from './choose-large-print-letter-template-page'; +export * from './choose-other-language-letter-template-page'; +export * from './choose-standard-letter-template-page'; +export * from './preview-large-print-letter-template-page'; +export * from './preview-other-language-letter-template-page'; +export * from './preview-standard-letter-page'; diff --git a/tests/test-team/pages/routing/nhs-app/index.ts b/tests/test-team/pages/routing/nhs-app/index.ts new file mode 100644 index 000000000..ab6aea5fa --- /dev/null +++ b/tests/test-team/pages/routing/nhs-app/index.ts @@ -0,0 +1,2 @@ +export * from './choose-nhs-app-template-page'; +export * from './preview-nhs-app-page'; diff --git a/tests/test-team/pages/routing/sms/index.ts b/tests/test-team/pages/routing/sms/index.ts new file mode 100644 index 000000000..691ebbf71 --- /dev/null +++ b/tests/test-team/pages/routing/sms/index.ts @@ -0,0 +1,2 @@ +export * from './choose-sms-template-page'; +export * from './preview-sms-template-page'; diff --git a/tests/test-team/pages/template-mgmt-base-page.ts b/tests/test-team/pages/template-mgmt-base-page.ts index b071ab465..2acc216a1 100644 --- a/tests/test-team/pages/template-mgmt-base-page.ts +++ b/tests/test-team/pages/template-mgmt-base-page.ts @@ -121,6 +121,18 @@ export abstract class TemplateMgmtBasePage { await this.backLinkTop.click(); } + async clickTemplatesHeaderLink() { + await this.headerNavigationLinks + .getByRole('link', { name: 'Templates' }) + .click(); + } + + async clickMessagePlansHeaderLink() { + await this.headerNavigationLinks + .getByRole('link', { name: 'Message plans' }) + .click(); + } + /** * Sets the value of a path parameter which will be interpolated into the pathTemplate when calling `getUrl` or `loadPage` * @param key The name of the path parameter to set diff --git a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-letter-file-validation.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/letter-file-validation.e2e.spec.ts similarity index 100% rename from tests/test-team/template-mgmt-e2e-tests/template-mgmt-letter-file-validation.e2e.spec.ts rename to tests/test-team/template-mgmt-e2e-tests/letter-file-validation.e2e.spec.ts diff --git a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-letter-full.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/letter-full.e2e.spec.ts similarity index 100% rename from tests/test-team/template-mgmt-e2e-tests/template-mgmt-letter-full.e2e.spec.ts rename to tests/test-team/template-mgmt-e2e-tests/letter-full.e2e.spec.ts diff --git a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-polling.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/proof-polling.e2e.spec.ts similarity index 100% rename from tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-polling.e2e.spec.ts rename to tests/test-team/template-mgmt-e2e-tests/proof-polling.e2e.spec.ts diff --git a/tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-request.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/proof-request.e2e.spec.ts similarity index 100% rename from tests/test-team/template-mgmt-e2e-tests/template-mgmt-proof-request.e2e.spec.ts rename to tests/test-team/template-mgmt-e2e-tests/proof-request.e2e.spec.ts diff --git a/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts b/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts new file mode 100644 index 000000000..ee2e0a6de --- /dev/null +++ b/tests/test-team/template-mgmt-e2e-tests/routing.e2e.spec.ts @@ -0,0 +1,428 @@ +import { expect, test, Locator } from '@playwright/test'; +import { + createAuthHelper, + TestUser, + testUsers, +} from '../helpers/auth/cognito-auth-helper'; +import { TemplateFactory } from '../helpers/factories/template-factory'; +import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; +import { randomUUID } from 'node:crypto'; +import { + RoutingChooseMessageOrderPage, + RoutingChooseTemplatesPage, + RoutingCreateMessagePlanPage, + RoutingMessagePlansPage, + RoutingChooseOtherLanguageLetterTemplatePage, + RoutingChooseNhsAppTemplatePage, + RoutingChooseEmailTemplatePage, + RoutingChooseTextMessageTemplatePage, + RoutingPreviewSmsTemplatePage, + RoutingChooseStandardLetterTemplatePage, + RoutingChooseLargePrintLetterTemplatePage, + RoutingGetReadyToMovePage, + RoutingReviewAndMoveToProductionPage, +} from '../pages/routing'; +import { TemplateMgmtMessageTemplatesPage } from '../pages/template-mgmt-message-templates-page'; +import { TemplateMgmtChooseTemplateForMessagePlanBasePage } from '../pages/template-mgmt-choose-template-base-page'; +import type { Template } from '../helpers/types'; +import type { Channel } from 'nhs-notify-backend-client'; + +const templateStorageHelper = new TemplateStorageHelper(); + +function createTemplates(user: TestUser) { + const templateIds = { + NHSAPP: randomUUID(), + EMAIL: randomUUID(), + SMS: randomUUID(), + LETTER: randomUUID(), + LARGE_PRINT_LETTER: randomUUID(), + ARABIC_LETTER: randomUUID(), + POLISH_LETTER: randomUUID(), + }; + + return { + NHSAPP: TemplateFactory.createNhsAppTemplate( + templateIds.NHSAPP, + user, + `E2E NHS App template - ${templateIds.NHSAPP}`, + 'SUBMITTED' + ), + EMAIL: TemplateFactory.createEmailTemplate( + templateIds.EMAIL, + user, + `E2E Email template - ${templateIds.EMAIL}`, + 'NOT_YET_SUBMITTED' + ), + SMS: TemplateFactory.createSmsTemplate( + templateIds.SMS, + user, + `E2E SMS template - ${templateIds.SMS}`, + 'NOT_YET_SUBMITTED' + ), + LETTER: TemplateFactory.createAuthoringLetterTemplate( + templateIds.LETTER, + user, + `E2E Letter template - ${templateIds.LETTER}`, + 'PROOF_APPROVED' + ), + LARGE_PRINT_LETTER: TemplateFactory.createAuthoringLetterTemplate( + templateIds.LARGE_PRINT_LETTER, + user, + `E2E Large Print Letter template - ${templateIds.LARGE_PRINT_LETTER}`, + 'PROOF_APPROVED', + { letterType: 'x1' } + ), + ARABIC_LETTER: TemplateFactory.createAuthoringLetterTemplate( + templateIds.ARABIC_LETTER, + user, + `E2E Letter template Arabic - ${templateIds.ARABIC_LETTER}`, + 'PROOF_APPROVED', + { language: 'ar' } + ), + POLISH_LETTER: TemplateFactory.createAuthoringLetterTemplate( + templateIds.POLISH_LETTER, + user, + `E2E Polish Letter template - ${templateIds.POLISH_LETTER}`, + 'SUBMITTED', + { language: 'pl' } + ), + }; +} + +async function selectTemplateRadio( + chooseTemplateLink: Locator, + chooseTemplatePage: TemplateMgmtChooseTemplateForMessagePlanBasePage, + template: Template, + templateNameLocator: Locator +) { + return test.step(`select template: ${template.name}`, async () => { + await chooseTemplateLink.click(); + + const radio = chooseTemplatePage.getRadioButton(template.id); + + await radio.click(); + + await chooseTemplatePage.saveAndContinueButton.click(); + + await expect(templateNameLocator).toHaveText(template.name); + }); +} + +async function assertTemplateStatuses( + messageTemplatesPage: TemplateMgmtMessageTemplatesPage, + expectations: Array<{ template: Template; expectedStatus: string }> +) { + return test.step('assert template statuses', async () => { + for (const { template, expectedStatus } of expectations) { + expect( + await messageTemplatesPage.getTemplateStatus(template.id), + `Expected ${template.name} to have status "${expectedStatus}"` + ).toBe(expectedStatus); + } + }); +} + +async function assertMessagePlanInTable( + table: Locator, + messagePlanName: string +) { + return test.step(`assert message plan "${messagePlanName}" is in table`, async () => { + await table.click(); + + const row = table.getByRole('row', { name: messagePlanName }); + + await expect(row).toBeVisible(); + + await row.getByRole('link', { name: messagePlanName }).click(); + }); +} + +test.describe('Routing', () => { + let templates: ReturnType; + let user: TestUser; + + test.beforeAll(async () => { + user = await createAuthHelper().getTestUser(testUsers.User1.userId); + templates = createTemplates(user); + + await templateStorageHelper.seedTemplateData(Object.values(templates)); + }); + + test.afterAll(async () => { + await templateStorageHelper.deleteSeededTemplates(); + }); + + test('templates are added to the routing config, and the routing config is completed', async ({ + page, + }) => { + const rcName = 'E2E TEST RC'; + + const messageTemplatesPage = new TemplateMgmtMessageTemplatesPage(page); + const messagePlansPage = new RoutingMessagePlansPage(page); + const chooseTemplatesPage = new RoutingChooseTemplatesPage(page); + + await test.step('check initial template statuses', async () => { + await messageTemplatesPage.loadPage(); + + await expect(messageTemplatesPage.pageHeading).toBeVisible(); + + await assertTemplateStatuses(messageTemplatesPage, [ + { template: templates.NHSAPP, expectedStatus: 'Locked' }, + { template: templates.POLISH_LETTER, expectedStatus: 'Locked' }, + { template: templates.EMAIL, expectedStatus: 'Draft' }, + { template: templates.SMS, expectedStatus: 'Draft' }, + { template: templates.LETTER, expectedStatus: 'Proof approved' }, + { + template: templates.LARGE_PRINT_LETTER, + expectedStatus: 'Proof approved', + }, + { template: templates.ARABIC_LETTER, expectedStatus: 'Proof approved' }, + ]); + }); + + await test.step('create routing config', async () => { + await messageTemplatesPage.clickMessagePlansHeaderLink(); + + await expect(messagePlansPage.pageHeading).toBeVisible(); + + await messagePlansPage.clickNewMessagePlanButton(); + + const chooseMessageOrderPage = new RoutingChooseMessageOrderPage(page); + + await chooseMessageOrderPage.checkRadioButton( + 'NHS App, Email, Text message, Letter' + ); + + await chooseMessageOrderPage.clickContinueButton(); + + const createMessagePlanPage = new RoutingCreateMessagePlanPage(page); + + await createMessagePlanPage.nameField.fill(rcName); + + await createMessagePlanPage.clickSubmit(); + }); + + await test.step('add other language letter templates', async () => { + await chooseTemplatesPage.letter.language.chooseTemplateLink.click(); + + const chooseOtherLanguageTemplatesPage = + new RoutingChooseOtherLanguageLetterTemplatePage(page); + + await expect( + chooseOtherLanguageTemplatesPage.tableRows.filter({ + hasText: templates.ARABIC_LETTER.name, + }) + ).toBeVisible(); + + await expect( + chooseOtherLanguageTemplatesPage.tableRows.filter({ + hasText: templates.POLISH_LETTER.name, + }) + ).toBeVisible(); + + await expect( + chooseOtherLanguageTemplatesPage.tableRows.filter({ + hasText: templates.LETTER.name, + }) + ).toBeHidden(); + + const plCheck = await chooseOtherLanguageTemplatesPage.getCheckbox( + templates.POLISH_LETTER.id + ); + + const arCheck = await chooseOtherLanguageTemplatesPage.getCheckbox( + templates.ARABIC_LETTER.id + ); + + await arCheck.click(); + await plCheck.click(); + + await chooseOtherLanguageTemplatesPage.saveAndContinueButton.click(); + + const otherLanguageNames = + chooseTemplatesPage.letter.language.templateNames; + + await expect(otherLanguageNames).toHaveCount(2); + + await expect( + otherLanguageNames.filter({ + hasText: templates.ARABIC_LETTER.name, + }) + ).toBeVisible(); + + await expect( + otherLanguageNames.filter({ + hasText: templates.POLISH_LETTER.name, + }) + ).toBeVisible(); + }); + + await test.step('check draft message plan exists', async () => { + await chooseTemplatesPage.clickMessagePlansHeaderLink(); + + await expect(messagePlansPage.pageHeading).toBeVisible(); + + await assertMessagePlanInTable( + messagePlansPage.draftMessagePlansTable, + rcName + ); + }); + + await test.step('add NHS App template', async () => { + await selectTemplateRadio( + chooseTemplatesPage.nhsApp.chooseTemplateLink, + new RoutingChooseNhsAppTemplatePage(page), + templates.NHSAPP, + chooseTemplatesPage.nhsApp.templateName + ); + }); + + await test.step('add Email template', async () => { + await selectTemplateRadio( + chooseTemplatesPage.email.chooseTemplateLink, + new RoutingChooseEmailTemplatePage(page), + templates.EMAIL, + chooseTemplatesPage.email.templateName + ); + }); + + await test.step('preview and add SMS template', async () => { + await chooseTemplatesPage.sms.chooseTemplateLink.click(); + + const chooseSmsTemplatePage = new RoutingChooseTextMessageTemplatePage( + page + ); + + const smsPreviewLink = chooseSmsTemplatePage.getPreviewLink( + templates.SMS.id + ); + + await smsPreviewLink.click(); + + const previewSmsTemplatePage = new RoutingPreviewSmsTemplatePage(page); + + await expect(previewSmsTemplatePage.templateId).toHaveText( + templates.SMS.id + ); + + await previewSmsTemplatePage.clickBackLinkTop(); + + const smsRadio = chooseSmsTemplatePage.getRadioButton(templates.SMS.id); + + await smsRadio.click(); + + await chooseSmsTemplatePage.saveAndContinueButton.click(); + + await expect(chooseTemplatesPage.sms.templateName).toHaveText( + templates.SMS.name + ); + }); + + await test.step('verify validation error for missing letter template', async () => { + await chooseTemplatesPage.clickMoveToProduction(); + + await expect(chooseTemplatesPage.errorSummaryList).toContainText([ + 'You have not chosen a template for your fourth message', + ]); + }); + + await test.step('add standard letter template', async () => { + await selectTemplateRadio( + chooseTemplatesPage.letter.standard.chooseTemplateLink, + new RoutingChooseStandardLetterTemplatePage(page), + templates.LETTER, + chooseTemplatesPage.letter.standard.templateName + ); + }); + + await test.step('add large print letter template', async () => { + await selectTemplateRadio( + chooseTemplatesPage.letter.largePrint.chooseTemplateLink, + new RoutingChooseLargePrintLetterTemplatePage(page), + templates.LARGE_PRINT_LETTER, + chooseTemplatesPage.letter.largePrint.templateName + ); + }); + + await test.step('remove large print letter template', async () => { + await chooseTemplatesPage.letter.largePrint.removeTemplateLink.click(); + + await expect( + chooseTemplatesPage.letter.largePrint.chooseTemplateLink + ).toBeVisible(); + }); + + await test.step('review and move to production', async () => { + await chooseTemplatesPage.clickMoveToProduction(); + + const getReadyToMovePage = new RoutingGetReadyToMovePage(page); + + await expect(getReadyToMovePage.pageHeading).toBeVisible(); + + await getReadyToMovePage.continueLink.click(); + + const reviewPage = new RoutingReviewAndMoveToProductionPage(page); + + await expect(reviewPage.pageHeading).toBeVisible(); + + const defaults: [Channel, Template][] = [ + ['NHSAPP', templates.NHSAPP], + ['EMAIL', templates.EMAIL], + ['SMS', templates.SMS], + ['LETTER', templates.LETTER], + ]; + + for (const [channel, defaultTemplate] of defaults) { + await expect( + reviewPage.getTemplateBlock(channel).defaultTemplateCard.templateName + ).toHaveText(defaultTemplate.name); + } + + const letterBlock = reviewPage.getTemplateBlock('LETTER'); + + // this template was removed + await expect( + letterBlock.getAccessibilityFormatCard('x1').locator + ).toBeHidden(); + + const languageTemplateNames = await letterBlock + .getLanguagesCard() + .templateName.allTextContents(); + + expect(languageTemplateNames).toHaveLength(2); + expect(languageTemplateNames).toContain(templates.ARABIC_LETTER.name); + expect(languageTemplateNames).toContain(templates.POLISH_LETTER.name); + + await reviewPage.moveToProductionButton.click(); + }); + + await test.step('verify message plan is in production', async () => { + await expect(messagePlansPage.pageHeading).toBeVisible(); + + await assertMessagePlanInTable( + messagePlansPage.productionMessagePlansTable, + rcName + ); + }); + + await test.step('verify all templates are locked (except removed large print letter)', async () => { + await messagePlansPage.clickTemplatesHeaderLink(); + + await expect(messageTemplatesPage.pageHeading).toBeVisible(); + + await assertTemplateStatuses(messageTemplatesPage, [ + { template: templates.NHSAPP, expectedStatus: 'Locked' }, + { template: templates.EMAIL, expectedStatus: 'Locked' }, + { template: templates.SMS, expectedStatus: 'Locked' }, + { template: templates.LETTER, expectedStatus: 'Locked' }, + { template: templates.ARABIC_LETTER, expectedStatus: 'Locked' }, + { template: templates.POLISH_LETTER, expectedStatus: 'Locked' }, + { + template: templates.LARGE_PRINT_LETTER, + // this was removed before going to production + expectedStatus: 'Proof approved', + }, + ]); + }); + }); +}); diff --git a/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts b/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts index 1fcc8f4a7..9684a4ec6 100644 --- a/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/digital-templates.event.spec.ts @@ -1,4 +1,7 @@ -import { test, expect } from '@playwright/test'; +import { + templateManagementEventSubscriber as test, + expect, +} from '../fixtures/template-management-event-subscriber'; import { createAuthHelper, type TestUser, @@ -6,14 +9,13 @@ import { } from '../helpers/auth/cognito-auth-helper'; import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; import { TemplateAPIPayloadFactory } from '../helpers/factories/template-api-payload-factory'; -import { EventCacheHelper } from '../helpers/events/event-cache-helper'; +import { eventWithId } from '../helpers/events/matchers'; const DIGITAL_CHANNELS = ['NHS_APP', 'SMS', 'EMAIL'] as const; test.describe('Event publishing - Digital', () => { const authHelper = createAuthHelper(); const templateStorageHelper = new TemplateStorageHelper(); - const eventCacheHelper = new EventCacheHelper(); let userRoutingEnabled: TestUser; let userRoutingDisabled: TestUser; @@ -31,6 +33,7 @@ test.describe('Event publishing - Digital', () => { test.describe(`${digitalChannel} template events`, () => { test('Expect Draft.v1 event When Creating And Updating templates And Completed.v1 event When submitting templates (routing disabled)', async ({ request, + eventSubscriber, }) => { const template = TemplateAPIPayloadFactory.getCreateTemplatePayload({ templateType: digitalChannel, @@ -55,7 +58,7 @@ test.describe('Event publishing - Digital', () => { } = await createResponse.json(); templateStorageHelper.addAdHocTemplateKey({ - templateId: templateId, + templateId, clientId: userRoutingDisabled.clientId, }); @@ -90,36 +93,45 @@ test.describe('Event publishing - Digital', () => { expect(submitResponse.status()).toBe(200); await expect(async () => { - const events = await eventCacheHelper.findEvents(start, [templateId]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithId(templateId), + }); expect(events).toHaveLength(3); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateDrafted.v1', - data: expect.objectContaining({ - id: templateId, - name, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateDrafted.v1', + data: expect.objectContaining({ + id: templateId, + name, + }), }), }) ); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateDrafted.v1', - data: expect.objectContaining({ - id: templateId, - name: 'UPDATED', + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateDrafted.v1', + data: expect.objectContaining({ + id: templateId, + name: 'UPDATED', + }), }), }) ); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', - data: expect.objectContaining({ - id: templateId, - name: 'UPDATED', + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', + data: expect.objectContaining({ + id: templateId, + name: 'UPDATED', + }), }), }) ); @@ -128,6 +140,7 @@ test.describe('Event publishing - Digital', () => { test('Expect Deleted.v1 event When deleting templates', async ({ request, + eventSubscriber, }) => { const template = TemplateAPIPayloadFactory.getCreateTemplatePayload({ templateType: digitalChannel, @@ -152,11 +165,11 @@ test.describe('Event publishing - Digital', () => { } = await createResponse.json(); templateStorageHelper.addAdHocTemplateKey({ - templateId: templateId, + templateId, clientId: userRoutingEnabled.clientId, }); - const updateResponse = await request.delete( + const deleteResponse = await request.delete( `${process.env.API_BASE_URL}/v1/template/${templateId}`, { headers: { @@ -166,27 +179,34 @@ test.describe('Event publishing - Digital', () => { } ); - expect(updateResponse.status()).toBe(204); + expect(deleteResponse.status()).toBe(204); await expect(async () => { - const events = await eventCacheHelper.findEvents(start, [templateId]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithId(templateId), + }); expect(events).toHaveLength(2); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateDrafted.v1', - data: expect.objectContaining({ - id: templateId, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateDrafted.v1', + data: expect.objectContaining({ + id: templateId, + }), }), }) ); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateDeleted.v1', - data: expect.objectContaining({ - id: templateId, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateDeleted.v1', + data: expect.objectContaining({ + id: templateId, + }), }), }) ); diff --git a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts index 87e6c96c5..13aec6488 100644 --- a/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/letter-templates.event.spec.ts @@ -1,11 +1,13 @@ -import { test, expect } from '@playwright/test'; +import { + templateManagementEventSubscriber as test, + expect, +} from '../fixtures/template-management-event-subscriber'; import { createAuthHelper, type TestUser, testUsers, } from '../helpers/auth/cognito-auth-helper'; import { TemplateStorageHelper } from '../helpers/db/template-storage-helper'; -import { EventCacheHelper } from '../helpers/events/event-cache-helper'; import { randomUUID } from 'node:crypto'; import { TemplateFactory } from '../helpers/factories/template-factory'; import { readFileSync } from 'node:fs'; @@ -13,11 +15,11 @@ import { SftpHelper } from '../helpers/sftp/sftp-helper'; import { InvokeCommand, LambdaClient } from '@aws-sdk/client-lambda'; import { setTimeout } from 'node:timers/promises'; import { Template } from 'helpers/types'; +import { eventWithId } from '../helpers/events/matchers'; test.describe('Event publishing - Letters', () => { const authHelper = createAuthHelper(); const templateStorageHelper = new TemplateStorageHelper(); - const eventCacheHelper = new EventCacheHelper(); const sftpHelper = new SftpHelper(); const lambdaClient = new LambdaClient({ region: 'eu-west-2' }); @@ -41,6 +43,7 @@ test.describe('Event publishing - Letters', () => { test('Expect no events when proofingEnabled is false', async ({ request, + eventSubscriber, }) => { const templateId = randomUUID(); @@ -74,13 +77,17 @@ test.describe('Event publishing - Letters', () => { // 5 seconds seems to largest delay when testing locally await setTimeout(5000); - const events = await eventCacheHelper.findEvents(start, [templateId]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithId(templateId), + }); expect(events).toHaveLength(0); }); test('expect no events when deleting a letter when previous status is not publishable', async ({ request, + eventSubscriber, }) => { const templateId = randomUUID(); @@ -112,13 +119,17 @@ test.describe('Event publishing - Letters', () => { // Note: not ideal - but we are expecting 0 events and there can be a delay await setTimeout(5000); - const events = await eventCacheHelper.findEvents(start, [templateId]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithId(templateId), + }); expect(events).toHaveLength(0); }); test('Expect Draft.v1 events When waiting for Proofs to become available And Completed.v1 event When submitting templates (routing disabled)', async ({ request, + eventSubscriber, }) => { const templateId = randomUUID(); @@ -209,7 +220,10 @@ test.describe('Event publishing - Letters', () => { expect(submitResponse.status()).toBe(200); await expect(async () => { - const events = await eventCacheHelper.findEvents(start, [templateId]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithId(templateId), + }); // Note: This is weird, But sometimes the tests find all relevant events within // 6 events and can never find the 7th event before the test times out. @@ -231,18 +245,19 @@ test.describe('Event publishing - Letters', () => { expect(events.length).toBeLessThanOrEqual(7); const drafts = events.filter( - (e) => - e.type === 'uk.nhs.notify.template-management.TemplateDrafted.v1' && - e.data.id === templateId + ({ record }) => + record.type === 'uk.nhs.notify.template-management.TemplateDrafted.v1' ); expect(drafts.length, JSON.stringify(events)).toBeGreaterThanOrEqual(5); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', - data: expect.objectContaining({ - id: templateId, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', + data: expect.objectContaining({ + id: templateId, + }), }), }) ); @@ -253,6 +268,7 @@ test.describe('Event publishing - Letters', () => { test('Expect Draft event when routing is enabled and proof is approved', async ({ request, + eventSubscriber, }) => { const templateId = randomUUID(); @@ -280,14 +296,16 @@ test.describe('Event publishing - Letters', () => { expect(submitResponse.status()).toBe(200); await expect(async () => { - const events = await eventCacheHelper.findEvents(start, [templateId]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithId(templateId), + }); expect(events).toHaveLength(2); const drafts = events.filter( - (e) => - e.type === 'uk.nhs.notify.template-management.TemplateDrafted.v1' && - e.data.id === templateId + ({ record }) => + record.type === 'uk.nhs.notify.template-management.TemplateDrafted.v1' ); expect(drafts).toHaveLength(2); @@ -296,6 +314,7 @@ test.describe('Event publishing - Letters', () => { test('Expect Deleted.v1 event when deleting templates', async ({ request, + eventSubscriber, }) => { const templateId = randomUUID(); @@ -328,24 +347,31 @@ test.describe('Event publishing - Letters', () => { expect(deletedResponse.status()).toBe(204); await expect(async () => { - const events = await eventCacheHelper.findEvents(start, [templateId]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithId(templateId), + }); expect(events).toHaveLength(2); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateDrafted.v1', - data: expect.objectContaining({ - id: templateId, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateDrafted.v1', + data: expect.objectContaining({ + id: templateId, + }), }), }) ); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.TemplateDeleted.v1', - data: expect.objectContaining({ - id: templateId, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateDeleted.v1', + data: expect.objectContaining({ + id: templateId, + }), }), }) ); diff --git a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts index 75c13f23e..85b73634c 100644 --- a/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts +++ b/tests/test-team/template-mgmt-event-tests/routing-config.event.spec.ts @@ -1,21 +1,52 @@ import { randomUUID } from 'node:crypto'; -import { test, expect } from '@playwright/test'; +import { + templateManagementEventSubscriber as test, + expect, +} from '../fixtures/template-management-event-subscriber'; import { createAuthHelper, type TestUser, testUsers, } from '../helpers/auth/cognito-auth-helper'; -import { EventCacheHelper } from '../helpers/events/event-cache-helper'; import { RoutingConfigStorageHelper } from 'helpers/db/routing-config-storage-helper'; import { RoutingConfigFactory } from 'helpers/factories/routing-config-factory'; import { TemplateFactory } from 'helpers/factories/template-factory'; import { TemplateStorageHelper } from 'helpers/db/template-storage-helper'; +import { eventWithId, eventWithIdIn } from '../helpers/events/matchers'; + +function createTemplates(user: TestUser) { + const templateIds = { + NHSAPP: randomUUID(), + EMAIL: randomUUID(), + LETTER: randomUUID(), + }; + + return { + NHSAPP: TemplateFactory.createNhsAppTemplate( + templateIds.NHSAPP, + user, + `Event NHS App Template - ${templateIds.NHSAPP}`, + 'NOT_YET_SUBMITTED' + ), + EMAIL: TemplateFactory.createEmailTemplate( + templateIds.EMAIL, + user, + `Event Email Template - ${templateIds.EMAIL}`, + 'SUBMITTED' + ), + LETTER: TemplateFactory.createAuthoringLetterTemplate( + templateIds.LETTER, + user, + `Event Letter Template - ${templateIds.LETTER}`, + 'PROOF_APPROVED' + ), + }; +} test.describe('Event publishing - Routing Config', () => { const authHelper = createAuthHelper(); - const storageHelper = new RoutingConfigStorageHelper(); + const routingConfigStorageHelper = new RoutingConfigStorageHelper(); const templateStorageHelper = new TemplateStorageHelper(); - const eventCacheHelper = new EventCacheHelper(); let user: TestUser; @@ -24,12 +55,13 @@ test.describe('Event publishing - Routing Config', () => { }); test.afterAll(async () => { - await storageHelper.deleteSeeded(); + await routingConfigStorageHelper.deleteSeeded(); await templateStorageHelper.deleteSeededTemplates(); }); test('Expect a draft event and a deleted event when some template IDs are null', async ({ request, + eventSubscriber, }) => { const payload = RoutingConfigFactory.create(user, { cascade: [ @@ -60,7 +92,7 @@ test.describe('Event publishing - Routing Config', () => { data: { id, lockNumber }, } = await createResponse.json(); - storageHelper.addAdHocKey({ + routingConfigStorageHelper.addAdHocKey({ id, clientId: user.clientId, }); @@ -78,22 +110,29 @@ test.describe('Event publishing - Routing Config', () => { expect(deleteResponse.status()).toBe(204); await expect(async () => { - const events = await eventCacheHelper.findEvents(start, [id]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithId(id), + }); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', - data: expect.objectContaining({ - id, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', + data: expect.objectContaining({ + id, + }), }), }) ); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.RoutingConfigDeleted.v1', - data: expect.objectContaining({ - id, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.RoutingConfigDeleted.v1', + data: expect.objectContaining({ + id, + }), }), }) ); @@ -102,7 +141,10 @@ test.describe('Event publishing - Routing Config', () => { }).toPass({ timeout: 60_000 }); }); - test('Expect a draft event and a deleted event', async ({ request }) => { + test('Expect a draft event and a deleted event', async ({ + request, + eventSubscriber, + }) => { const payload = RoutingConfigFactory.create(user, { cascade: [ { @@ -132,7 +174,7 @@ test.describe('Event publishing - Routing Config', () => { data: { id, lockNumber }, } = await createResponse.json(); - storageHelper.addAdHocKey({ + routingConfigStorageHelper.addAdHocKey({ id, clientId: user.clientId, }); @@ -150,22 +192,29 @@ test.describe('Event publishing - Routing Config', () => { expect(deleteResponse.status()).toBe(204); await expect(async () => { - const events = await eventCacheHelper.findEvents(start, [id]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithId(id), + }); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', - data: expect.objectContaining({ - id, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', + data: expect.objectContaining({ + id, + }), }), }) ); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.RoutingConfigDeleted.v1', - data: expect.objectContaining({ - id, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.RoutingConfigDeleted.v1', + data: expect.objectContaining({ + id, + }), }), }) ); @@ -174,16 +223,25 @@ test.describe('Event publishing - Routing Config', () => { }).toPass({ timeout: 60_000 }); }); - test('Expect a draft event and a completed event', async ({ request }) => { - const templateId = randomUUID(); + test('Expect routing config and template completed events on submit', async ({ + request, + eventSubscriber, + }) => { + const templates = createTemplates(user); + const seedStart = new Date(); + await templateStorageHelper.seedTemplateData(Object.values(templates)); - const template = TemplateFactory.createNhsAppTemplate( - templateId, - user, - 'Test Template for Submit' - ); + // Wait for seeding events to arrive before proceeding + await expect(async () => { + const seedEvents = await eventSubscriber.receive({ + since: seedStart, + // Authoring letters don't produce events yet + match: eventWithIdIn([templates.NHSAPP.id, templates.EMAIL.id]), + }); + expect(seedEvents.length).toBe(2); + }).toPass({ timeout: 60_000 }); - await templateStorageHelper.seedTemplateData([template]); + const start = new Date(); const payload = RoutingConfigFactory.create(user, { cascade: [ @@ -191,13 +249,23 @@ test.describe('Event publishing - Routing Config', () => { cascadeGroups: ['standard'], channel: 'NHSAPP', channelType: 'primary', - defaultTemplateId: templateId, + defaultTemplateId: templates.NHSAPP.id, + }, + { + cascadeGroups: ['standard'], + channel: 'EMAIL', + channelType: 'primary', + defaultTemplateId: templates.EMAIL.id, + }, + { + cascadeGroups: ['standard'], + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: templates.LETTER.id, }, ], }).apiPayload; - const start = new Date(); - const createResponse = await request.post( `${process.env.API_BASE_URL}/v1/routing-configuration`, { @@ -211,16 +279,16 @@ test.describe('Event publishing - Routing Config', () => { expect(createResponse.status()).toBe(201); const { - data: { id, lockNumber }, + data: { id: routingConfigId, lockNumber }, } = await createResponse.json(); - storageHelper.addAdHocKey({ - id, + routingConfigStorageHelper.addAdHocKey({ + id: routingConfigId, clientId: user.clientId, }); const submitResponse = await request.patch( - `${process.env.API_BASE_URL}/v1/routing-configuration/${id}/submit`, + `${process.env.API_BASE_URL}/v1/routing-configuration/${routingConfigId}/submit`, { headers: { Authorization: await user.getAccessToken(), @@ -232,27 +300,74 @@ test.describe('Event publishing - Routing Config', () => { expect(submitResponse.status()).toBe(200); await expect(async () => { - const events = await eventCacheHelper.findEvents(start, [id]); + const events = await eventSubscriber.receive({ + since: start, + match: eventWithIdIn([ + routingConfigId, + templates.EMAIL.id, + templates.NHSAPP.id, + templates.LETTER.id, + ]), + }); + + expect(events).toHaveLength(3); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', - data: expect.objectContaining({ - id, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.RoutingConfigDrafted.v1', + data: expect.objectContaining({ + id: routingConfigId, + }), }), }) ); expect(events).toContainEqual( expect.objectContaining({ - type: 'uk.nhs.notify.template-management.RoutingConfigCompleted.v1', - data: expect.objectContaining({ - id, + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.RoutingConfigCompleted.v1', + data: expect.objectContaining({ + id: routingConfigId, + }), }), }) ); - expect(events).toHaveLength(2); + expect(events).toContainEqual( + expect.objectContaining({ + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', + data: expect.objectContaining({ + id: templates.NHSAPP.id, + }), + }), + }) + ); + + // This was already submitted + expect(events).not.toContainEqual( + expect.objectContaining({ + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', + data: expect.objectContaining({ + id: templates.EMAIL.id, + }), + }), + }) + ); + + // AUTHORING letters don't produce events yet + expect(events).not.toContainEqual( + expect.objectContaining({ + record: expect.objectContaining({ + type: 'uk.nhs.notify.template-management.TemplateCompleted.v1', + data: expect.objectContaining({ + id: templates.LETTER.id, + }), + }), + }) + ); }).toPass({ timeout: 60_000 }); }); }); diff --git a/tests/test-team/template-mgmt-routing-component-tests/choose-templates.routing-component.spec.ts b/tests/test-team/template-mgmt-routing-component-tests/choose-templates.routing-component.spec.ts index d2bfd3c66..a6b539bf6 100644 --- a/tests/test-team/template-mgmt-routing-component-tests/choose-templates.routing-component.spec.ts +++ b/tests/test-team/template-mgmt-routing-component-tests/choose-templates.routing-component.spec.ts @@ -453,8 +453,13 @@ test.describe('Routing - Choose Templates page', () => { }); await test.step('letter channel with no template selected has no name or change link', async () => { - await expect(chooseTemplatesPage.letter.templateName).toBeHidden(); - await expect(chooseTemplatesPage.letter.changeTemplateLink).toBeHidden(); + await expect( + chooseTemplatesPage.letter.standard.templateName + ).toBeHidden(); + + await expect( + chooseTemplatesPage.letter.standard.changeTemplateLink + ).toBeHidden(); }); await chooseTemplatesPage.nhsApp.clickChangeTemplateLink(); @@ -490,7 +495,9 @@ test.describe('Routing - Choose Templates page', () => { await chooseTemplatesPage.nhsApp.clickRemoveTemplateLink(); - await expect(chooseTemplatesPage.letter.removeTemplateLink).toBeHidden(); + await expect( + chooseTemplatesPage.letter.standard.removeTemplateLink + ).toBeHidden(); await expect(page).toHaveURL( `${baseURL}/templates/message-plans/choose-templates/${routingConfigIds.valid}` @@ -568,18 +575,28 @@ test.describe('Routing - Choose Templates page', () => { await chooseTemplatesPage.loadPage(); await test.step('standard letter channel with default template has template name and change link', async () => { - await expect(chooseTemplatesPage.letter.templateName).toHaveText( + await expect(chooseTemplatesPage.letter.standard.templateName).toHaveText( templates.LETTER.name ); - await expect(chooseTemplatesPage.letter.changeTemplateLink).toBeVisible(); + + await expect( + chooseTemplatesPage.letter.standard.changeTemplateLink + ).toBeVisible(); + await expect( - chooseTemplatesPage.letter.changeTemplateLink + chooseTemplatesPage.letter.standard.changeTemplateLink ).toHaveAttribute( 'href', `/templates/message-plans/choose-standard-english-letter-template/${routingConfigIds.validWithLetterTemplates}?lockNumber=${messagePlans.validWithLetterTemplates.lockNumber}` ); - await expect(chooseTemplatesPage.letter.removeTemplateLink).toBeVisible(); - await expect(chooseTemplatesPage.letter.chooseTemplateLink).toBeHidden(); + + await expect( + chooseTemplatesPage.letter.standard.removeTemplateLink + ).toBeVisible(); + + await expect( + chooseTemplatesPage.letter.standard.chooseTemplateLink + ).toBeHidden(); }); const alternativeLetterFormats = diff --git a/utils/backend-config/src/backend-config.ts b/utils/backend-config/src/backend-config.ts index b7c95bcbb..6dace6a49 100644 --- a/utils/backend-config/src/backend-config.ts +++ b/utils/backend-config/src/backend-config.ts @@ -4,8 +4,10 @@ import fs from 'node:fs'; export type BackendConfig = { apiBaseUrl: string; + awsAccountId: string; clientSsmPathPrefix: string; - eventCacheBucketName: string; + environment: string; + eventsSnsTopicArn: string; requestProofQueueUrl: string; routingConfigTableName: string; sftpEnvironment: string; @@ -25,8 +27,10 @@ export const BackendConfigHelper = { fromEnv(): BackendConfig { return { apiBaseUrl: process.env.API_BASE_URL ?? '', + awsAccountId: process.env.AWS_ACCOUNT_ID ?? '', clientSsmPathPrefix: process.env.CLIENT_SSM_PATH_PREFIX ?? '', - eventCacheBucketName: process.env.EVENT_CACHE_BUCKET_NAME ?? '', + environment: process.env.ENVIRONMENT ?? '', + eventsSnsTopicArn: process.env.EVENTS_SNS_TOPIC_ARN ?? '', requestProofQueueUrl: process.env.REQUEST_PROOF_QUEUE_URL ?? '', routingConfigTableName: process.env.ROUTING_CONFIG_TABLE_NAME ?? '', sftpEnvironment: process.env.SFTP_ENVIRONMENT ?? '', @@ -48,8 +52,10 @@ export const BackendConfigHelper = { toEnv(config: BackendConfig): void { process.env.API_BASE_URL = config.apiBaseUrl; + process.env.AWS_ACCOUNT_ID = config.awsAccountId; process.env.CLIENT_SSM_PATH_PREFIX = config.clientSsmPathPrefix; - process.env.EVENT_CACHE_BUCKET_NAME = config.eventCacheBucketName; + process.env.ENVIRONMENT = config.environment; + process.env.EVENTS_SNS_TOPIC_ARN = config.eventsSnsTopicArn; process.env.COGNITO_USER_POOL_ID = config.userPoolId; process.env.COGNITO_USER_POOL_CLIENT_ID = config.userPoolClientId; process.env.TEMPLATES_TABLE_NAME = config.templatesTableName; @@ -70,13 +76,15 @@ export const BackendConfigHelper = { fromTerraformOutputsFile(filepath: string): BackendConfig { const outputsFileContent = JSON.parse(fs.readFileSync(filepath, 'utf8')); + const deployment = outputsFileContent.deployment?.value ?? {}; return { apiBaseUrl: outputsFileContent.api_base_url?.value ?? '', + awsAccountId: deployment.aws_account_id ?? '', clientSsmPathPrefix: outputsFileContent.client_ssm_path_prefix?.value ?? '', - eventCacheBucketName: - outputsFileContent.event_cache_bucket_name?.value ?? '', + environment: deployment.environment ?? '', + eventsSnsTopicArn: outputsFileContent.events_sns_topic_arn?.value ?? '', requestProofQueueUrl: outputsFileContent.request_proof_queue_url?.value ?? '', routingConfigTableName: