-
+
{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: