diff --git a/infrastructure/terraform/modules/backend-api/README.md b/infrastructure/terraform/modules/backend-api/README.md index 1ef7b2c65..b1cdec30c 100644 --- a/infrastructure/terraform/modules/backend-api/README.md +++ b/infrastructure/terraform/modules/backend-api/README.md @@ -39,6 +39,7 @@ No requirements. |------|--------|---------| | [authorizer\_lambda](#module\_authorizer\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip | n/a | | [count\_routing\_configs\_lambda](#module\_count\_routing\_configs\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip | n/a | +| [create\_routing\_config\_lambda](#module\_create\_routing\_config\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip | n/a | | [create\_template\_lambda](#module\_create\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip | n/a | | [delete\_template\_lambda](#module\_delete\_template\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip | n/a | | [get\_client\_lambda](#module\_get\_client\_lambda) | https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip | n/a | diff --git a/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf b/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf index db31b3e2e..26d13cd2b 100644 --- a/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf +++ b/infrastructure/terraform/modules/backend-api/iam_role_api_gateway_execution_role.tf @@ -52,6 +52,7 @@ data "aws_iam_policy_document" "api_gateway_execution_policy" { module.upload_letter_template_lambda.function_arn, module.count_routing_configs_lambda.function_arn, module.create_template_lambda.function_arn, + module.create_routing_config_lambda.function_arn, module.delete_template_lambda.function_arn, module.get_client_lambda.function_arn, module.get_routing_config_lambda.function_arn, diff --git a/infrastructure/terraform/modules/backend-api/locals.tf b/infrastructure/terraform/modules/backend-api/locals.tf index 368c2fafd..cf46795f3 100644 --- a/infrastructure/terraform/modules/backend-api/locals.tf +++ b/infrastructure/terraform/modules/backend-api/locals.tf @@ -16,6 +16,7 @@ locals { AWS_REGION = var.region COUNT_ROUTING_CONFIGS_LAMBDA_ARN = module.count_routing_configs_lambda.function_arn CREATE_LAMBDA_ARN = module.create_template_lambda.function_arn + CREATE_ROUTING_CONFIG_LAMBDA_ARN = module.create_routing_config_lambda.function_arn DELETE_LAMBDA_ARN = module.delete_template_lambda.function_arn GET_CLIENT_LAMBDA_ARN = module.get_client_lambda.function_arn GET_LAMBDA_ARN = module.get_template_lambda.function_arn diff --git a/infrastructure/terraform/modules/backend-api/module_create_routing_config_lambda.tf b/infrastructure/terraform/modules/backend-api/module_create_routing_config_lambda.tf new file mode 100644 index 000000000..e45bf0f04 --- /dev/null +++ b/infrastructure/terraform/modules/backend-api/module_create_routing_config_lambda.tf @@ -0,0 +1,68 @@ +module "create_routing_config_lambda" { + source = "https://github.com/NHSDigital/nhs-notify-shared-modules/releases/download/v2.0.22/terraform-lambda.zip" + + project = var.project + environment = var.environment + component = var.component + aws_account_id = var.aws_account_id + region = var.region + + kms_key_arn = var.kms_key_arn + + function_name = "create-routing-config" + + function_module_name = "create-routing-config" + handler_function_name = "handler" + description = "Create Routing Config API endpoint" + + memory = 512 + timeout = 3 + runtime = "nodejs20.x" + + log_retention_in_days = var.log_retention_in_days + + iam_policy_document = { + body = data.aws_iam_policy_document.create_routing_config_lambda_policy.json + } + + lambda_env_vars = local.backend_lambda_environment_variables + function_s3_bucket = var.function_s3_bucket + function_code_base_path = local.lambdas_dir + function_code_dir = "backend-api/dist/create-routing-config" + + send_to_firehose = var.send_to_firehose + log_destination_arn = var.log_destination_arn + log_subscription_role_arn = var.log_subscription_role_arn +} + +data "aws_iam_policy_document" "create_routing_config_lambda_policy" { + statement { + sid = "AllowDynamoAccess" + effect = "Allow" + + actions = [ + "dynamodb:PutItem", + ] + + resources = [ + aws_dynamodb_table.routing_configuration.arn, + ] + } + + statement { + sid = "AllowKMSAccess" + effect = "Allow" + + actions = [ + "kms:Decrypt", + "kms:DescribeKey", + "kms:Encrypt", + "kms:GenerateDataKey*", + "kms:ReEncrypt*", + ] + + resources = [ + var.kms_key_arn + ] + } +} diff --git a/infrastructure/terraform/modules/backend-api/spec.tmpl.json b/infrastructure/terraform/modules/backend-api/spec.tmpl.json index 1599f8905..d048be38d 100644 --- a/infrastructure/terraform/modules/backend-api/spec.tmpl.json +++ b/infrastructure/terraform/modules/backend-api/spec.tmpl.json @@ -297,7 +297,7 @@ "ClientConfiguration": { "properties": { "campaignId": { - "deprecared": true, + "deprecated": true, "type": "string" }, "campaignIds": { @@ -399,6 +399,35 @@ ], "type": "object" }, + "CreateUpdateRoutingConfig": { + "properties": { + "campaignId": { + "type": "string" + }, + "cascade": { + "items": { + "$ref": "#/components/schemas/CascadeItem" + }, + "type": "array" + }, + "cascadeGroupOverrides": { + "items": { + "$ref": "#/components/schemas/CascadeGroup" + }, + "type": "array" + }, + "name": { + "type": "string" + } + }, + "required": [ + "campaignId", + "cascadeGroupOverrides", + "cascade", + "name" + ], + "type": "object" + }, "CreateUpdateTemplate": { "allOf": [ { @@ -617,9 +646,6 @@ "format": "date-time", "type": "string" }, - "createdBy": { - "type": "string" - }, "id": { "format": "uuid", "type": "string" @@ -627,18 +653,12 @@ "name": { "type": "string" }, - "owner": { - "type": "string" - }, "status": { "$ref": "#/components/schemas/RoutingConfigStatus" }, "updatedAt": { "format": "date-time", "type": "string" - }, - "updatedBy": { - "type": "string" } }, "required": [ @@ -647,14 +667,10 @@ "cascade", "clientId", "createdAt", - "createdBy", - "displayCategory", "id", "name", - "owner", "status", - "updatedAt", - "updatedBy" + "updatedAt" ], "type": "object" }, @@ -1011,6 +1027,71 @@ } } }, + "/v1/routing-configuration": { + "post": { + "description": "Create a routing configuration", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/CreateUpdateRoutingConfig" + } + } + }, + "description": "Routing configuration to create", + "required": true + }, + "responses": { + "201": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/RoutingConfigSuccess" + } + } + }, + "description": "201 response", + "headers": { + "Content-Type": { + "schema": { + "type": "string" + } + } + } + }, + "default": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/Failure" + } + } + }, + "description": "Error" + } + }, + "security": [ + { + "authorizer": [] + } + ], + "summary": "Create a routing configuration by ID", + "x-amazon-apigateway-integration": { + "contentHandling": "CONVERT_TO_TEXT", + "credentials": "${APIG_EXECUTION_ROLE_ARN}", + "httpMethod": "POST", + "passthroughBehavior": "WHEN_NO_TEMPLATES", + "responses": { + ".*": { + "statusCode": "200" + } + }, + "timeoutInMillis": 29000, + "type": "AWS_PROXY", + "uri": "arn:aws:apigateway:${AWS_REGION}:lambda:path/2015-03-31/functions/${CREATE_ROUTING_CONFIG_LAMBDA_ARN}/invocations" + } + } + }, "/v1/routing-configuration/{routingConfigId}": { "get": { "description": "Get a routing configuration by ID", diff --git a/lambdas/backend-api/README.md b/lambdas/backend-api/README.md index a4417a5ad..d4e2c64dc 100644 --- a/lambdas/backend-api/README.md +++ b/lambdas/backend-api/README.md @@ -16,16 +16,10 @@ Setup an authenticated AWS terminal and run ./scripts/sandbox_auth.sh ``` -Grab the `AccessToken` from `sandbox_cognito_auth_token.json` +Grab the `AccessToken` and `api_base_url` from `sandbox_cognito_auth_token.json` ```bash -SANDBOX_TOKEN=$(jq -r .AccessToken sandbox_cognito_auth_token.json) -``` - -Set APIG stage. The following extracts the value from `sandbox_tf_outputs.tf`. Values resemble `https://cc5p8d4l3b.execute-api.eu-west-2.amazonaws.com/main` - -```bash -APIG_STAGE=$(jq -r .api_base_url.value ./sandbox_tf_outputs.json) +SANDBOX_TOKEN=$(jq -r .AccessToken sandbox_cognito_auth_token.json) && APIG_STAGE=$(jq -r .api_base_url.value ./sandbox_tf_outputs.json) ``` ### GET - /v1/template/:templateId - Get a single template by id @@ -153,7 +147,26 @@ curl --location "${APIG_STAGE}/v1/routing-configuration/${ROUTING_CONFIG_ID}" \ ### GET - /v1/routing-configurations - List routing configurations ```bash -curl --location "${APIG_STAGE}/v1/routing-configurations \ +curl --location "${APIG_STAGE}/v1/routing-configurations" \ --header 'Accept: application/json' \ --header "Authorization: $SANDBOX_TOKEN" ``` + +### POST - /v1/routing-configuration - Create a routing configuration + +```bash +curl -X POST --location "${APIG_STAGE}/v1/routing-configuration" \ +--header 'Content-Type: application/json' \ +--header 'Accept: application/json' \ +--header "Authorization: $SANDBOX_TOKEN" \ +--data '{ + "campaignId": "campaign", + "cascade": [{ + "cascadeGroups": ["standard"], + "channel": "EMAIL", + "channelType": "primary", + "defaultTemplateId": "email_id" + }], + "cascadeGroupOverrides": [{ "name": "standard" }], + "name": "RC name" +}' diff --git a/lambdas/backend-api/build.sh b/lambdas/backend-api/build.sh index 21ec3a323..044127dc2 100755 --- a/lambdas/backend-api/build.sh +++ b/lambdas/backend-api/build.sh @@ -16,14 +16,15 @@ npx esbuild \ --external:pdfjs-dist \ src/templates/copy-scanned-object-to-internal.ts \ src/templates/count-routing-configs.ts \ + src/templates/create-routing-config.ts \ src/templates/create.ts \ - src/templates/delete.ts \ src/templates/delete-failed-scanned-object.ts \ - src/templates/get.ts \ + src/templates/delete.ts \ src/templates/get-client.ts \ src/templates/get-routing-config.ts \ - src/templates/list.ts \ + src/templates/get.ts \ src/templates/list-routing-configs.ts \ + src/templates/list.ts \ src/templates/process-proof.ts \ src/templates/proof.ts \ src/templates/set-letter-upload-virus-scan-status.ts \ diff --git a/lambdas/backend-api/src/__tests__/templates/api/count-routing-configs.test.ts b/lambdas/backend-api/src/__tests__/templates/api/count-routing-configs.test.ts index 156b28233..813b86ebf 100644 --- a/lambdas/backend-api/src/__tests__/templates/api/count-routing-configs.test.ts +++ b/lambdas/backend-api/src/__tests__/templates/api/count-routing-configs.test.ts @@ -81,7 +81,7 @@ describe('CountRoutingConfigs handler', () => { }); expect(mocks.routingConfigClient.countRoutingConfigs).toHaveBeenCalledWith( - 'nhs-notify-client-id', + { userId: 'sub', clientId: 'nhs-notify-client-id' }, { status: 'DRAFT' } ); }); @@ -110,7 +110,7 @@ describe('CountRoutingConfigs handler', () => { }); expect(mocks.routingConfigClient.countRoutingConfigs).toHaveBeenCalledWith( - 'nhs-notify-client-id', + { userId: 'sub', clientId: 'nhs-notify-client-id' }, { status: 'COMPLETED' } ); }); diff --git a/lambdas/backend-api/src/__tests__/templates/api/create-routing-config.test.ts b/lambdas/backend-api/src/__tests__/templates/api/create-routing-config.test.ts new file mode 100644 index 000000000..52b8d7628 --- /dev/null +++ b/lambdas/backend-api/src/__tests__/templates/api/create-routing-config.test.ts @@ -0,0 +1,180 @@ +import type { APIGatewayProxyEvent, Context } from 'aws-lambda'; +import { mock } from 'jest-mock-extended'; +import { + CreateUpdateRoutingConfig, + RoutingConfig, +} from 'nhs-notify-backend-client'; +import { createHandler } from '@backend-api/templates/api/create-routing-config'; +import type { RoutingConfigClient } from '@backend-api/templates/app/routing-config-client'; + +function setup() { + const routingConfigClient = mock(); + const mocks = { routingConfigClient }; + const handler = createHandler(mocks); + + return { handler, mocks }; +} + +describe('Create Routing Config Handler', () => { + beforeEach(jest.resetAllMocks); + + test.each([ + ['undefined', undefined], + ['missing user', { clientId: 'client-id', user: undefined }], + ['missing client', { clientId: undefined, user: 'user-id' }], + ])( + 'should return 400 - Invalid request when requestContext is %s', + async (_, ctx) => { + const { handler, mocks } = setup(); + + const event = mock({ + requestContext: { authorizer: ctx }, + body: JSON.stringify({}), + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + statusCode: 400, + technicalMessage: 'Invalid request', + }), + }); + + expect( + mocks.routingConfigClient.createRoutingConfig + ).not.toHaveBeenCalled(); + } + ); + + test('should return 400 - Invalid request when no body', async () => { + const { handler, mocks } = setup(); + + mocks.routingConfigClient.createRoutingConfig.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 400, + description: 'Validation failed', + details: { + templateType: 'Invalid input: expected string, received undefined', + }, + }, + }, + data: undefined, + }); + + const event = mock({ + requestContext: { + authorizer: { user: 'sub', clientId: 'nhs-notify-client-id' }, + }, + body: undefined, + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 400, + body: JSON.stringify({ + statusCode: 400, + technicalMessage: 'Validation failed', + details: { + templateType: 'Invalid input: expected string, received undefined', + }, + }), + }); + + expect(mocks.routingConfigClient.createRoutingConfig).toHaveBeenCalledWith( + {}, + { userId: 'sub', clientId: 'nhs-notify-client-id' } + ); + }); + + test('should return error when creating routing config fails', async () => { + const { handler, mocks } = setup(); + + mocks.routingConfigClient.createRoutingConfig.mockResolvedValueOnce({ + error: { + errorMeta: { + code: 500, + description: 'Internal server error', + }, + }, + }); + + const event = mock({ + requestContext: { + authorizer: { user: 'sub', clientId: 'nhs-notify-client-id' }, + }, + body: JSON.stringify({ id: 1 }), + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 500, + body: JSON.stringify({ + statusCode: 500, + technicalMessage: 'Internal server error', + }), + }); + + expect(mocks.routingConfigClient.createRoutingConfig).toHaveBeenCalledWith( + { id: 1 }, + { userId: 'sub', clientId: 'nhs-notify-client-id' } + ); + }); + + test('should return created routing config', async () => { + const { handler, mocks } = setup(); + + const create: CreateUpdateRoutingConfig = { + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'NHSAPP', + channelType: 'primary', + defaultTemplateId: 'apptemplate', + }, + ], + cascadeGroupOverrides: [{ name: 'standard' }], + name: 'app RC', + campaignId: 'campaign', + }; + + const response: RoutingConfig = { + ...create, + id: 'id', + status: 'DRAFT', + createdAt: new Date().toISOString(), + updatedAt: new Date().toISOString(), + clientId: 'nhs-notify-client-id', + }; + + mocks.routingConfigClient.createRoutingConfig.mockResolvedValueOnce({ + data: response, + }); + + const event = mock({ + requestContext: { + authorizer: { user: 'sub', clientId: 'notify-client-id' }, + }, + body: JSON.stringify(create), + }); + + const result = await handler(event, mock(), jest.fn()); + + expect(result).toEqual({ + statusCode: 201, + body: JSON.stringify({ statusCode: 201, data: response }), + }); + + expect(mocks.routingConfigClient.createRoutingConfig).toHaveBeenCalledWith( + create, + { + userId: 'sub', + clientId: 'notify-client-id', + } + ); + }); +}); diff --git a/lambdas/backend-api/src/__tests__/templates/api/get-routing-config.test.ts b/lambdas/backend-api/src/__tests__/templates/api/get-routing-config.test.ts index 17b6d3c2e..621bf1bed 100644 --- a/lambdas/backend-api/src/__tests__/templates/api/get-routing-config.test.ts +++ b/lambdas/backend-api/src/__tests__/templates/api/get-routing-config.test.ts @@ -106,7 +106,7 @@ describe('GetRoutingConfig handler', () => { expect(mocks.routingConfigClient.getRoutingConfig).toHaveBeenCalledWith( '3690d344-731f-4f60-9047-2c63c96623a2', - 'nhs-notify-client-id' + { userId: 'sub', clientId: 'nhs-notify-client-id' } ); }); @@ -135,7 +135,7 @@ describe('GetRoutingConfig handler', () => { expect(mocks.routingConfigClient.getRoutingConfig).toHaveBeenCalledWith( '3690d344-731f-4f60-9047-2c63c96623a2', - 'nhs-notify-client-id' + { userId: 'sub', clientId: 'nhs-notify-client-id' } ); }); }); diff --git a/lambdas/backend-api/src/__tests__/templates/api/list-routing-configs.test.ts b/lambdas/backend-api/src/__tests__/templates/api/list-routing-configs.test.ts index 88ff60286..4cbc2d639 100644 --- a/lambdas/backend-api/src/__tests__/templates/api/list-routing-configs.test.ts +++ b/lambdas/backend-api/src/__tests__/templates/api/list-routing-configs.test.ts @@ -82,7 +82,7 @@ describe('ListRoutingConfig handler', () => { }); expect(mocks.routingConfigClient.listRoutingConfigs).toHaveBeenCalledWith( - 'nhs-notify-client-id', + { clientId: 'nhs-notify-client-id', userId: 'sub' }, { status: 'DRAFT' } ); }); @@ -92,12 +92,10 @@ describe('ListRoutingConfig handler', () => { const list = [ makeRoutingConfig({ - owner: 'nhs-notify-client-id', clientId: 'nhs-notify-client-id', status: 'COMPLETED', }), makeRoutingConfig({ - owner: 'nhs-notify-client-id', clientId: 'nhs-notify-client-id', status: 'COMPLETED', }), @@ -124,7 +122,7 @@ describe('ListRoutingConfig handler', () => { }); expect(mocks.routingConfigClient.listRoutingConfigs).toHaveBeenCalledWith( - 'nhs-notify-client-id', + { clientId: 'nhs-notify-client-id', userId: 'sub' }, { status: 'COMPLETED' } ); }); diff --git a/lambdas/backend-api/src/__tests__/templates/app/routing-config-client.test.ts b/lambdas/backend-api/src/__tests__/templates/app/routing-config-client.test.ts index 7891ea859..c0f724a91 100644 --- a/lambdas/backend-api/src/__tests__/templates/app/routing-config-client.test.ts +++ b/lambdas/backend-api/src/__tests__/templates/app/routing-config-client.test.ts @@ -3,10 +3,19 @@ import type { RoutingConfigRepository } from '@backend-api/templates/infra/routi import { RoutingConfigQuery } from '@backend-api/templates/infra/routing-config-repository/query'; import { RoutingConfigClient } from '@backend-api/templates/app/routing-config-client'; import { routingConfig } from '../fixtures/routing-config'; +import { + CreateUpdateRoutingConfig, + RoutingConfig, +} from 'nhs-notify-backend-client'; + +const user = { userId: 'userid', clientId: 'nhs-notify-client-id' }; function setup() { const repo = mock(); - const mocks = { routingConfigRepository: repo }; + + const mocks = { + routingConfigRepository: repo, + }; const client = new RoutingConfigClient(repo); @@ -19,6 +28,7 @@ function mockQuery() { excludeStatus: jest.fn().mockReturnThis(), }); } + describe('RoutingConfigClient', () => { describe('getRoutingConfig', () => { test('returns the routing config from the repository', async () => { @@ -30,7 +40,7 @@ describe('RoutingConfigClient', () => { const result = await client.getRoutingConfig( '3690d344-731f-4f60-9047-2c63c96623a2', - 'nhs-notify-client-id' + user ); expect(result).toEqual({ @@ -39,7 +49,7 @@ describe('RoutingConfigClient', () => { expect(mocks.routingConfigRepository.get).toHaveBeenCalledWith( '3690d344-731f-4f60-9047-2c63c96623a2', - 'nhs-notify-client-id' + user.clientId ); }); @@ -54,7 +64,7 @@ describe('RoutingConfigClient', () => { const result = await client.getRoutingConfig( '3690d344-731f-4f60-9047-2c63c96623a2', - 'nhs-notify-client-id' + user ); expect(result).toEqual({ @@ -65,7 +75,7 @@ describe('RoutingConfigClient', () => { expect(mocks.routingConfigRepository.get).toHaveBeenCalledWith( '3690d344-731f-4f60-9047-2c63c96623a2', - 'nhs-notify-client-id' + user.clientId ); }); @@ -78,7 +88,7 @@ describe('RoutingConfigClient', () => { const result = await client.getRoutingConfig( '3690d344-731f-4f60-9047-2c63c96623a2', - 'nhs-notify-client-id' + user ); expect(result).toEqual({ @@ -89,7 +99,7 @@ describe('RoutingConfigClient', () => { expect(mocks.routingConfigRepository.get).toHaveBeenCalledWith( '3690d344-731f-4f60-9047-2c63c96623a2', - 'nhs-notify-client-id' + user.clientId ); }); }); @@ -104,7 +114,7 @@ describe('RoutingConfigClient', () => { query.list.mockResolvedValueOnce({ data: [routingConfig] }); - const result = await client.listRoutingConfigs('nhs-notify-client-id'); + const result = await client.listRoutingConfigs(user); expect(result).toEqual({ data: [routingConfig] }); @@ -118,7 +128,7 @@ describe('RoutingConfigClient', () => { it('validates status filter parameter', async () => { const { client, mocks } = setup(); - const result = await client.listRoutingConfigs('nhs-notify-client-id', { + const result = await client.listRoutingConfigs(user, { status: 'INVALID', }); @@ -146,7 +156,7 @@ describe('RoutingConfigClient', () => { query.list.mockResolvedValueOnce({ data: [routingConfig] }); - const result = await client.listRoutingConfigs('nhs-notify-client-id', { + const result = await client.listRoutingConfigs(user, { status: 'DRAFT', }); @@ -170,12 +180,12 @@ describe('RoutingConfigClient', () => { query.count.mockResolvedValueOnce({ data: { count: 3 } }); - const result = await client.countRoutingConfigs('nhs-notify-client-id'); + const result = await client.countRoutingConfigs(user); expect(result).toEqual({ data: { count: 3 } }); expect(mocks.routingConfigRepository.query).toHaveBeenCalledWith( - 'nhs-notify-client-id' + user.clientId ); expect(query.excludeStatus).toHaveBeenCalledWith('DELETED'); expect(query.status).not.toHaveBeenCalled(); @@ -184,7 +194,7 @@ describe('RoutingConfigClient', () => { it('validates status filter parameter', async () => { const { client, mocks } = setup(); - const result = await client.countRoutingConfigs('nhs-notify-client-id', { + const result = await client.countRoutingConfigs(user, { status: 'INVALID', }); @@ -212,17 +222,141 @@ describe('RoutingConfigClient', () => { query.count.mockResolvedValueOnce({ data: { count: 18 } }); - const result = await client.countRoutingConfigs('nhs-notify-client-id', { + const result = await client.countRoutingConfigs(user, { status: 'DRAFT', }); expect(result).toEqual({ data: { count: 18 } }); expect(mocks.routingConfigRepository.query).toHaveBeenCalledWith( - 'nhs-notify-client-id' + user.clientId ); expect(query.excludeStatus).toHaveBeenCalledWith('DELETED'); expect(query.status).toHaveBeenCalledWith('DRAFT'); }); }); + + describe('createRoutingConfig', () => { + test('returns created routing config', async () => { + const { client, mocks } = setup(); + + const date = new Date(); + + const input: CreateUpdateRoutingConfig = { + name: 'rc', + campaignId: 'campaign', + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'SMS', + channelType: 'primary', + defaultTemplateId: 'sms', + }, + ], + cascadeGroupOverrides: [{ name: 'standard' }], + }; + + const rc: RoutingConfig = { + ...input, + clientId: user.clientId, + createdAt: date.toISOString(), + id: 'id', + status: 'DRAFT', + updatedAt: date.toISOString(), + }; + + mocks.routingConfigRepository.create.mockResolvedValueOnce({ + data: rc, + }); + + const result = await client.createRoutingConfig(input, user); + + expect(mocks.routingConfigRepository.create).toHaveBeenCalledWith( + input, + user + ); + + expect(result).toEqual({ + data: rc, + }); + }); + + test('returns 400 error when input is invalid', async () => { + const { client, mocks } = setup(); + + const result = await client.createRoutingConfig( + { a: 1 } as unknown as CreateUpdateRoutingConfig, + user + ); + + expect(mocks.routingConfigRepository.create).not.toHaveBeenCalled(); + + expect(result).toEqual({ + error: { + actualError: { + fieldErrors: { + campaignId: [ + 'Invalid input: expected string, received undefined', + ], + cascade: ['Invalid input: expected array, received undefined'], + cascadeGroupOverrides: [ + 'Invalid input: expected array, received undefined', + ], + name: ['Invalid input: expected string, received undefined'], + }, + formErrors: [], + }, + errorMeta: { + code: 400, + description: 'Request failed validation', + details: { + campaignId: 'Invalid input: expected string, received undefined', + cascade: 'Invalid input: expected array, received undefined', + cascadeGroupOverrides: + 'Invalid input: expected array, received undefined', + name: 'Invalid input: expected string, received undefined', + }, + }, + }, + }); + }); + + test('returns failures from the repository', async () => { + const { client, mocks } = setup(); + + const input: CreateUpdateRoutingConfig = { + name: 'rc', + campaignId: 'campaign', + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'SMS', + channelType: 'primary', + defaultTemplateId: 'sms', + }, + ], + cascadeGroupOverrides: [{ name: 'standard' }], + }; + + mocks.routingConfigRepository.create.mockResolvedValueOnce({ + error: { errorMeta: { code: 500, description: 'ddb err' } }, + }); + + const result = await client.createRoutingConfig(input, user); + + expect(mocks.routingConfigRepository.create).toHaveBeenCalledWith( + input, + user + ); + + expect(result).toEqual({ + error: { + errorMeta: { + code: 500, + description: 'ddb err', + }, + }, + }); + }); + }); }); diff --git a/lambdas/backend-api/src/__tests__/templates/fixtures/routing-config.ts b/lambdas/backend-api/src/__tests__/templates/fixtures/routing-config.ts index bec2458c3..60fc2fd10 100644 --- a/lambdas/backend-api/src/__tests__/templates/fixtures/routing-config.ts +++ b/lambdas/backend-api/src/__tests__/templates/fixtures/routing-config.ts @@ -14,16 +14,15 @@ export const routingConfig: RoutingConfig = { ], cascadeGroupOverrides: [{ name: 'standard' }], id: 'b9b6d56b-421e-462f-9ce5-3012e3fdb27f', - owner: 'client-1', status: 'DRAFT', name: 'Test config', createdAt: '2025-09-18T15:26:04.338Z', - createdBy: 'user-1', updatedAt: '2025-09-18T15:26:04.338Z', - updatedBy: 'user-1', }; -export const makeRoutingConfig = (overrides: Partial = {}) => ({ +export const makeRoutingConfig = ( + overrides: Partial = {} +): RoutingConfig => ({ ...routingConfig, id: randomUUID(), createdAt: new Date().toISOString(), diff --git a/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/query.test.ts b/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/query.test.ts index 4f394943d..d536ee843 100644 --- a/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/query.test.ts +++ b/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/query.test.ts @@ -10,11 +10,12 @@ jest.mock('nhs-notify-web-template-management-utils/logger'); const TABLE_NAME = 'routing-config-table-name'; -const owner = '89077697-ca6d-47fc-b233-3281fbd15579'; +const clientId = '89077697-ca6d-47fc-b233-3281fbd15579'; +const clientOwnerKey = `CLIENT#${clientId}`; -const config1 = makeRoutingConfig({ owner, status: 'DRAFT' }); -const config2 = makeRoutingConfig({ owner, status: 'COMPLETED' }); -const config3 = makeRoutingConfig({ owner, status: 'DELETED' }); +const config1 = makeRoutingConfig({ clientId, status: 'DRAFT' }); +const config2 = makeRoutingConfig({ clientId, status: 'COMPLETED' }); +const config3 = makeRoutingConfig({ clientId, status: 'DELETED' }); function setup() { const dynamo = mockClient(DynamoDBDocumentClient); @@ -43,13 +44,13 @@ describe('RoutingConfigRepo#query', () => { .on(QueryCommand) .resolvesOnce({ Items: page1, - LastEvaluatedKey: { owner, id: config2.id }, + LastEvaluatedKey: { owner: clientOwnerKey, id: config2.id }, }) .resolvesOnce({ Items: page2, }); - const result = await repo.query(owner).list(); + const result = await repo.query(clientId).list(); expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 2); expect(mocks.dynamo).toHaveReceivedNthCommandWith(1, QueryCommand, { @@ -59,9 +60,9 @@ describe('RoutingConfigRepo#query', () => { '#owner': 'owner', }, ExpressionAttributeValues: { - ':owner': owner, + ':owner': clientOwnerKey, }, - ExclusiveStartKey: { owner, id: config2.id }, + ExclusiveStartKey: { owner: clientOwnerKey, id: config2.id }, }); expect(mocks.dynamo).toHaveReceivedNthCommandWith(2, QueryCommand, { TableName: TABLE_NAME, @@ -70,7 +71,7 @@ describe('RoutingConfigRepo#query', () => { '#owner': 'owner', }, ExpressionAttributeValues: { - ':owner': owner, + ':owner': clientOwnerKey, }, }); @@ -85,7 +86,7 @@ describe('RoutingConfigRepo#query', () => { }); await repo - .query(owner) + .query(clientId) .status('COMPLETED', 'DELETED') .status('DRAFT') .list(); @@ -100,7 +101,7 @@ describe('RoutingConfigRepo#query', () => { '#status': 'status', }, ExpressionAttributeValues: { - ':owner': owner, + ':owner': clientOwnerKey, ':status0': 'COMPLETED', ':status1': 'DELETED', ':status2': 'DRAFT', @@ -116,7 +117,7 @@ describe('RoutingConfigRepo#query', () => { }); await repo - .query(owner) + .query(clientId) .excludeStatus('COMPLETED', 'DELETED') .excludeStatus('DRAFT') .list(); @@ -132,7 +133,7 @@ describe('RoutingConfigRepo#query', () => { '#status': 'status', }, ExpressionAttributeValues: { - ':owner': owner, + ':owner': clientOwnerKey, ':notStatus0': 'COMPLETED', ':notStatus1': 'DELETED', ':notStatus2': 'DRAFT', @@ -147,7 +148,11 @@ describe('RoutingConfigRepo#query', () => { Items: [], }); - await repo.query(owner).status('DRAFT').excludeStatus('DELETED').list(); + await repo + .query(clientId) + .status('DRAFT') + .excludeStatus('DELETED') + .list(); expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 1); expect(mocks.dynamo).toHaveReceivedCommandWith(QueryCommand, { @@ -160,7 +165,7 @@ describe('RoutingConfigRepo#query', () => { '#status': 'status', }, ExpressionAttributeValues: { - ':owner': owner, + ':owner': clientOwnerKey, ':notStatus0': 'DELETED', ':status0': 'DRAFT', }, @@ -175,7 +180,7 @@ describe('RoutingConfigRepo#query', () => { }); await repo - .query(owner) + .query(clientId) .status('DRAFT') .status('DRAFT') .excludeStatus('DELETED') @@ -193,7 +198,7 @@ describe('RoutingConfigRepo#query', () => { '#status': 'status', }, ExpressionAttributeValues: { - ':owner': owner, + ':owner': clientOwnerKey, ':notStatus0': 'DELETED', ':status0': 'DRAFT', }, @@ -206,12 +211,12 @@ describe('RoutingConfigRepo#query', () => { mocks.dynamo.on(QueryCommand).resolvesOnce({ Items: [ config1, - { owner, id: '2eb0b8f5-63f0-4512-8a95-5b82e7c4b07b' }, + { owner: clientOwnerKey, id: '2eb0b8f5-63f0-4512-8a95-5b82e7c4b07b' }, config2, ], }); - const result = await repo.query(owner).list(); + const result = await repo.query(clientId).list(); expect(result.data).toEqual([config1, config2]); }); @@ -221,7 +226,7 @@ describe('RoutingConfigRepo#query', () => { mocks.dynamo.on(QueryCommand).resolvesOnce({}); - const result = await repo.query(owner).list(); + const result = await repo.query(clientId).list(); expect(result.data).toEqual([]); }); @@ -233,7 +238,7 @@ describe('RoutingConfigRepo#query', () => { mocks.dynamo.on(QueryCommand).rejectsOnce(e); - const result = await repo.query(owner).list(); + const result = await repo.query(clientId).list(); expect(result.error).toMatchObject({ actualError: e, @@ -251,13 +256,13 @@ describe('RoutingConfigRepo#query', () => { .on(QueryCommand) .resolvesOnce({ Count: 2, - LastEvaluatedKey: { owner, id: config2.id }, + LastEvaluatedKey: { owner: clientOwnerKey, id: config2.id }, }) .resolvesOnce({ Count: 1, }); - const result = await repo.query(owner).count(); + const result = await repo.query(clientId).count(); expect(mocks.dynamo).toHaveReceivedCommandTimes(QueryCommand, 2); expect(mocks.dynamo).toHaveReceivedNthCommandWith(1, QueryCommand, { @@ -267,10 +272,10 @@ describe('RoutingConfigRepo#query', () => { '#owner': 'owner', }, ExpressionAttributeValues: { - ':owner': owner, + ':owner': clientOwnerKey, }, Select: 'COUNT', - ExclusiveStartKey: { owner, id: config2.id }, + ExclusiveStartKey: { owner: clientOwnerKey, id: config2.id }, }); expect(mocks.dynamo).toHaveReceivedNthCommandWith(2, QueryCommand, { TableName: TABLE_NAME, @@ -279,7 +284,7 @@ describe('RoutingConfigRepo#query', () => { '#owner': 'owner', }, ExpressionAttributeValues: { - ':owner': owner, + ':owner': clientOwnerKey, }, Select: 'COUNT', }); @@ -292,7 +297,7 @@ describe('RoutingConfigRepo#query', () => { mocks.dynamo.on(QueryCommand).resolvesOnce({}); - const result = await repo.query(owner).count(); + const result = await repo.query(clientId).count(); expect(result.data).toEqual({ count: 0 }); }); @@ -304,7 +309,7 @@ describe('RoutingConfigRepo#query', () => { mocks.dynamo.on(QueryCommand).rejectsOnce(e); - const result = await repo.query(owner).count(); + const result = await repo.query(clientId).count(); expect(result.error).toMatchObject({ actualError: e, diff --git a/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/repository.test.ts b/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/repository.test.ts index b1ddfbe16..09000ae17 100644 --- a/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/repository.test.ts +++ b/lambdas/backend-api/src/__tests__/templates/infra/routing-config-repository/repository.test.ts @@ -1,11 +1,33 @@ -import { DynamoDBDocumentClient, GetCommand } from '@aws-sdk/lib-dynamodb'; +import { + DynamoDBDocumentClient, + GetCommand, + PutCommand, +} from '@aws-sdk/lib-dynamodb'; import 'aws-sdk-client-mock-jest'; import { mockClient } from 'aws-sdk-client-mock'; import { ZodError } from 'zod'; import { RoutingConfigRepository } from '@backend-api/templates/infra/routing-config-repository'; import { routingConfig } from '../../fixtures/routing-config'; +import { + CreateUpdateRoutingConfig, + RoutingConfig, +} from 'nhs-notify-backend-client'; +import { randomUUID, type UUID } from 'node:crypto'; + +jest.mock('node:crypto'); +const uuidMock = jest.mocked(randomUUID); +const generatedId = 'c4846505-5380-4601-a361-f82f650adfee'; +uuidMock.mockReturnValue(generatedId); + +const date = new Date(2024, 11, 27); + +beforeAll(() => { + jest.useFakeTimers(); + jest.setSystemTime(date); +}); const TABLE_NAME = 'routing-config-table-name'; +const user = { userId: 'user', clientId: 'nhs-notify-client-id' }; function setup() { const dynamo = mockClient(DynamoDBDocumentClient); @@ -31,7 +53,7 @@ describe('RoutingConfigRepository', () => { const result = await repo.get( 'b9b6d56b-421e-462f-9ce5-3012e3fdb27f', - 'nhs-notify-client-id' + user.clientId ); expect(result).toEqual({ data: routingConfig }); @@ -40,7 +62,7 @@ describe('RoutingConfigRepository', () => { TableName: TABLE_NAME, Key: { id: 'b9b6d56b-421e-462f-9ce5-3012e3fdb27f', - owner: 'nhs-notify-client-id', + owner: `CLIENT#${user.clientId}`, }, }); }); @@ -54,7 +76,7 @@ describe('RoutingConfigRepository', () => { const result = await repo.get( 'b9b6d56b-421e-462f-9ce5-3012e3fdb27f', - 'nhs-notify-client-id' + user.clientId ); expect(result).toEqual({ @@ -67,7 +89,7 @@ describe('RoutingConfigRepository', () => { TableName: TABLE_NAME, Key: { id: 'b9b6d56b-421e-462f-9ce5-3012e3fdb27f', - owner: 'nhs-notify-client-id', + owner: `CLIENT#${user.clientId}`, }, }); }); @@ -81,7 +103,7 @@ describe('RoutingConfigRepository', () => { const result = await repo.get( 'b9b6d56b-421e-462f-9ce5-3012e3fdb27f', - 'nhs-notify-client-id' + user.clientId ); expect(result.error).toMatchObject({ @@ -96,7 +118,100 @@ describe('RoutingConfigRepository', () => { TableName: TABLE_NAME, Key: { id: 'b9b6d56b-421e-462f-9ce5-3012e3fdb27f', - owner: 'nhs-notify-client-id', + owner: `CLIENT#${user.clientId}`, + }, + }); + }); + }); + + describe('create', () => { + const input: CreateUpdateRoutingConfig = { + name: 'rc', + campaignId: 'campaign', + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'SMS', + channelType: 'primary', + defaultTemplateId: 'sms', + }, + ], + cascadeGroupOverrides: [{ name: 'standard' }], + }; + + const rc: RoutingConfig = { + ...input, + clientId: user.clientId, + createdAt: date.toISOString(), + id: generatedId, + status: 'DRAFT', + updatedAt: date.toISOString(), + }; + + const putPayload = { + ...rc, + owner: `CLIENT#${user.clientId}`, + createdBY: user.userId, + updatedBy: user.userId, + }; + + test('should create routing config', async () => { + const { repo, mocks } = setup(); + + mocks.dynamo + .on(PutCommand, { + TableName: TABLE_NAME, + Item: putPayload, + }) + .resolves({}); + + const result = await repo.create(input, user); + + expect(result).toEqual({ data: rc }); + }); + + test('returns failure if put fails', async () => { + const { repo, mocks } = setup(); + + const err = new Error('ddb_err'); + + mocks.dynamo.on(PutCommand).rejects(err); + + const result = await repo.create(input, user); + + expect(result).toEqual({ + error: { + actualError: err, + errorMeta: { + code: 500, + description: 'Failed to create routing config', + }, + }, + }); + }); + + test('returns failure if constructed routing config is invalid', async () => { + const { repo } = setup(); + + uuidMock.mockReturnValueOnce('not_a_uuid' as UUID); + + const result = await repo.create(input, user); + + expect(result).toEqual({ + error: { + actualError: expect.objectContaining({ + issues: expect.arrayContaining([ + expect.objectContaining({ + path: ['id'], + code: 'invalid_format', + format: 'uuid', + }), + ]), + }), + errorMeta: { + code: 500, + description: 'Failed to create routing config', + }, }, }); }); diff --git a/lambdas/backend-api/src/templates/api/count-routing-configs.ts b/lambdas/backend-api/src/templates/api/count-routing-configs.ts index db14d1bf8..26e446a5e 100644 --- a/lambdas/backend-api/src/templates/api/count-routing-configs.ts +++ b/lambdas/backend-api/src/templates/api/count-routing-configs.ts @@ -15,13 +15,12 @@ export function createHandler({ return apiFailure(400, 'Invalid request'); } - const log = logger.child({ - clientId, - userId, - }); + const user = { userId, clientId }; + + const log = logger.child(user); const { data, error } = await routingConfigClient.countRoutingConfigs( - clientId, + user, event.queryStringParameters ); diff --git a/lambdas/backend-api/src/templates/api/create-routing-config.ts b/lambdas/backend-api/src/templates/api/create-routing-config.ts new file mode 100644 index 000000000..1577e300f --- /dev/null +++ b/lambdas/backend-api/src/templates/api/create-routing-config.ts @@ -0,0 +1,46 @@ +import type { APIGatewayProxyHandler } from 'aws-lambda'; +import { apiFailure, apiSuccess } from './responses'; +import type { RoutingConfigClient } from '../app/routing-config-client'; +import { logger } from 'nhs-notify-web-template-management-utils/logger'; + +export function createHandler({ + routingConfigClient, +}: { + routingConfigClient: RoutingConfigClient; +}): APIGatewayProxyHandler { + return async function handler(event) { + const { user: userId, clientId } = event.requestContext.authorizer ?? {}; + + if (!clientId || !userId) { + return apiFailure(400, 'Invalid request'); + } + + const payload = JSON.parse(event.body || '{}'); + + const user = { + clientId, + userId, + }; + + const log = logger.child(user); + + const { data, error } = await routingConfigClient.createRoutingConfig( + payload, + user + ); + + if (error) { + log + .child(error.errorMeta) + .error('Failed to create routing configuration', error.actualError); + + return apiFailure( + error.errorMeta.code, + error.errorMeta.description, + error.errorMeta.details + ); + } + + return apiSuccess(201, data); + }; +} diff --git a/lambdas/backend-api/src/templates/api/get-routing-config.ts b/lambdas/backend-api/src/templates/api/get-routing-config.ts index a2fe48814..04f2b0549 100644 --- a/lambdas/backend-api/src/templates/api/get-routing-config.ts +++ b/lambdas/backend-api/src/templates/api/get-routing-config.ts @@ -25,7 +25,7 @@ export function createHandler({ const { data, error } = await routingConfigClient.getRoutingConfig( routingConfigId, - clientId + { clientId, userId } ); if (error) { diff --git a/lambdas/backend-api/src/templates/api/list-routing-configs.ts b/lambdas/backend-api/src/templates/api/list-routing-configs.ts index e6d52b5bd..cbd9088a1 100644 --- a/lambdas/backend-api/src/templates/api/list-routing-configs.ts +++ b/lambdas/backend-api/src/templates/api/list-routing-configs.ts @@ -15,13 +15,15 @@ export function createHandler({ return apiFailure(400, 'Invalid request'); } - const log = logger.child({ + const user = { clientId, userId, - }); + }; + + const log = logger.child(user); const { data, error } = await routingConfigClient.listRoutingConfigs( - clientId, + user, event.queryStringParameters ); diff --git a/lambdas/backend-api/src/templates/app/routing-config-client.ts b/lambdas/backend-api/src/templates/app/routing-config-client.ts index afad79849..8c3834713 100644 --- a/lambdas/backend-api/src/templates/app/routing-config-client.ts +++ b/lambdas/backend-api/src/templates/app/routing-config-client.ts @@ -1,24 +1,46 @@ import { failure } from '@backend-api/utils/result'; import { + $CreateUpdateRoutingConfig, $ListRoutingConfigFilters, ErrorCase, + type CreateUpdateRoutingConfig, type ListRoutingConfigFilters, type Result, type RoutingConfig, } from 'nhs-notify-backend-client'; import { validate } from '@backend-api/utils/validate'; import type { RoutingConfigRepository } from '../infra/routing-config-repository'; +import type { User } from 'nhs-notify-web-template-management-utils'; export class RoutingConfigClient { constructor( private readonly routingConfigRepository: RoutingConfigRepository ) {} + async createRoutingConfig( + routingConfig: CreateUpdateRoutingConfig, + user: User + ): Promise> { + const validationResult = await validate( + $CreateUpdateRoutingConfig, + routingConfig + ); + + if (validationResult.error) return validationResult; + + const createResult = await this.routingConfigRepository.create( + validationResult.data, + user + ); + + return createResult; + } + async getRoutingConfig( id: string, - owner: string + user: User ): Promise> { - const result = await this.routingConfigRepository.get(id, owner); + const result = await this.routingConfigRepository.get(id, user.clientId); if (result.data?.status === 'DELETED') { return failure(ErrorCase.NOT_FOUND, 'Routing Config not found'); @@ -28,7 +50,7 @@ export class RoutingConfigClient { } async listRoutingConfigs( - owner: string, + user: User, filters?: unknown ): Promise> { let parsedFilters: ListRoutingConfigFilters = {}; @@ -44,7 +66,7 @@ export class RoutingConfigClient { } const query = this.routingConfigRepository - .query(owner) + .query(user.clientId) .excludeStatus('DELETED'); if (parsedFilters.status) { @@ -55,7 +77,7 @@ export class RoutingConfigClient { } async countRoutingConfigs( - owner: string, + user: User, filters?: unknown ): Promise> { let parsedFilters: ListRoutingConfigFilters = {}; @@ -71,7 +93,7 @@ export class RoutingConfigClient { } const query = this.routingConfigRepository - .query(owner) + .query(user.clientId) .excludeStatus('DELETED'); if (parsedFilters.status) { diff --git a/lambdas/backend-api/src/templates/create-routing-config.ts b/lambdas/backend-api/src/templates/create-routing-config.ts new file mode 100644 index 000000000..8b6b97c5f --- /dev/null +++ b/lambdas/backend-api/src/templates/create-routing-config.ts @@ -0,0 +1,4 @@ +import { createHandler } from './api/create-routing-config'; +import { createContainer } from './container'; + +export const handler = createHandler(createContainer()); diff --git a/lambdas/backend-api/src/templates/infra/routing-config-repository/repository.ts b/lambdas/backend-api/src/templates/infra/routing-config-repository/repository.ts index 6d7e16524..1d45b4baf 100644 --- a/lambdas/backend-api/src/templates/infra/routing-config-repository/repository.ts +++ b/lambdas/backend-api/src/templates/infra/routing-config-repository/repository.ts @@ -1,10 +1,21 @@ -import { GetCommand, type DynamoDBDocumentClient } from '@aws-sdk/lib-dynamodb'; -import { ApplicationResult, failure, success } from '@backend-api/utils/result'; +import { randomUUID } from 'node:crypto'; +import { + GetCommand, + PutCommand, + type DynamoDBDocumentClient, +} from '@aws-sdk/lib-dynamodb'; +import { + type ApplicationResult, + failure, + success, +} from '@backend-api/utils/result'; import { $RoutingConfig, + type CreateUpdateRoutingConfig, ErrorCase, type RoutingConfig, } from 'nhs-notify-backend-client'; +import type { User } from 'nhs-notify-web-template-management-utils'; import { RoutingConfigQuery } from './query'; export class RoutingConfigRepository { @@ -13,9 +24,44 @@ export class RoutingConfigRepository { private readonly tableName: string ) {} + async create(routingConfigInput: CreateUpdateRoutingConfig, user: User) { + const date = new Date().toISOString(); + + try { + const routingConfig = $RoutingConfig.parse({ + ...routingConfigInput, + clientId: user.clientId, + createdAt: date, + id: randomUUID(), + status: 'DRAFT', + updatedAt: date, + }); + + await this.client.send( + new PutCommand({ + TableName: this.tableName, + Item: { + ...routingConfig, + owner: this.clientOwnerKey(user.clientId), + updatedBy: user.userId, + createdBy: user.userId, + }, + }) + ); + + return success(routingConfig); + } catch (error) { + return failure( + ErrorCase.INTERNAL, + 'Failed to create routing config', + error + ); + } + } + async get( id: string, - owner: string + clientId: string ): Promise> { try { const result = await this.client.send( @@ -23,7 +69,7 @@ export class RoutingConfigRepository { TableName: this.tableName, Key: { id, - owner, + owner: this.clientOwnerKey(clientId), }, }) ); @@ -44,7 +90,15 @@ export class RoutingConfigRepository { } } - query(owner: string): RoutingConfigQuery { - return new RoutingConfigQuery(this.client, this.tableName, owner); + query(clientId: string): RoutingConfigQuery { + return new RoutingConfigQuery( + this.client, + this.tableName, + this.clientOwnerKey(clientId) + ); + } + + private clientOwnerKey(clientId: string) { + return `CLIENT#${clientId}`; } } diff --git a/lambdas/backend-client/src/__tests__/schemas/__snapshots__/routing-config.test.ts.snap b/lambdas/backend-client/src/__tests__/schemas/__snapshots__/routing-config.test.ts.snap index d365c81f5..90419d5a0 100644 --- a/lambdas/backend-client/src/__tests__/schemas/__snapshots__/routing-config.test.ts.snap +++ b/lambdas/backend-client/src/__tests__/schemas/__snapshots__/routing-config.test.ts.snap @@ -1,6 +1,6 @@ // Jest Snapshot v1, https://goo.gl/fbAQLP -exports[`RoutingConfig schema snapshot full error 1`] = ` +exports[`CreateUpdateRoutingConfig schema snapshot full error 1`] = ` [ZodError: [ { "expected": "string", @@ -10,14 +10,6 @@ exports[`RoutingConfig schema snapshot full error 1`] = ` ], "message": "Invalid input: expected string, received undefined" }, - { - "expected": "string", - "code": "invalid_type", - "path": [ - "clientId" - ], - "message": "Invalid input: expected string, received undefined" - }, { "expected": "array", "code": "invalid_type", @@ -38,29 +30,38 @@ exports[`RoutingConfig schema snapshot full error 1`] = ` "expected": "string", "code": "invalid_type", "path": [ - "id" + "name" ], "message": "Invalid input: expected string, received undefined" - }, + } +]] +`; + +exports[`RoutingConfig schema snapshot full error 1`] = ` +[ZodError: [ { "expected": "string", "code": "invalid_type", "path": [ - "owner" + "campaignId" ], "message": "Invalid input: expected string, received undefined" }, { - "code": "invalid_value", - "values": [ - "COMPLETED", - "DRAFT", - "DELETED" + "expected": "array", + "code": "invalid_type", + "path": [ + "cascade" ], + "message": "Invalid input: expected array, received undefined" + }, + { + "expected": "array", + "code": "invalid_type", "path": [ - "status" + "cascadeGroupOverrides" ], - "message": "Invalid option: expected one of \\"COMPLETED\\"|\\"DRAFT\\"|\\"DELETED\\"" + "message": "Invalid input: expected array, received undefined" }, { "expected": "string", @@ -74,7 +75,7 @@ exports[`RoutingConfig schema snapshot full error 1`] = ` "expected": "string", "code": "invalid_type", "path": [ - "createdAt" + "clientId" ], "message": "Invalid input: expected string, received undefined" }, @@ -82,15 +83,27 @@ exports[`RoutingConfig schema snapshot full error 1`] = ` "expected": "string", "code": "invalid_type", "path": [ - "createdBy" + "id" ], "message": "Invalid input: expected string, received undefined" }, + { + "code": "invalid_value", + "values": [ + "COMPLETED", + "DRAFT", + "DELETED" + ], + "path": [ + "status" + ], + "message": "Invalid option: expected one of \\"COMPLETED\\"|\\"DRAFT\\"|\\"DELETED\\"" + }, { "expected": "string", "code": "invalid_type", "path": [ - "updatedAt" + "createdAt" ], "message": "Invalid input: expected string, received undefined" }, @@ -98,7 +111,7 @@ exports[`RoutingConfig schema snapshot full error 1`] = ` "expected": "string", "code": "invalid_type", "path": [ - "updatedBy" + "updatedAt" ], "message": "Invalid input: expected string, received undefined" } diff --git a/lambdas/backend-client/src/__tests__/schemas/routing-config.test.ts b/lambdas/backend-client/src/__tests__/schemas/routing-config.test.ts index 4fefce752..8dc95da7a 100644 --- a/lambdas/backend-client/src/__tests__/schemas/routing-config.test.ts +++ b/lambdas/backend-client/src/__tests__/schemas/routing-config.test.ts @@ -1,79 +1,84 @@ import { + $CreateUpdateRoutingConfig, $RoutingConfig, $ListRoutingConfigFilters, } from '../../schemas/routing-config'; -describe('RoutingConfig schema', () => { - const base = { - campaignId: 'campaign-1', - clientId: 'client-1', - cascade: [ - { - cascadeGroups: ['standard'], - channel: 'LETTER', - channelType: 'primary', - defaultTemplateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09', - }, - ], - cascadeGroupOverrides: [ - { name: 'standard' }, - { name: 'translations', language: ['ar', 'zh'] }, - { name: 'accessible', accessibleFormat: ['x1', 'x0'] }, - ], - id: 'b9b6d56b-421e-462f-9ce5-3012e3fdb27f', - owner: 'client-1', - status: 'DRAFT', - name: 'Test config', - createdAt: '2025-09-18T15:26:04.338Z', - createdBy: 'user-1', - updatedAt: '2025-09-18T15:26:04.338Z', - updatedBy: 'user-1', - }; +const baseInput = { + campaignId: 'campaign-1', + cascade: [ + { + cascadeGroups: ['standard'], + channel: 'LETTER', + channelType: 'primary', + defaultTemplateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09', + }, + ], + cascadeGroupOverrides: [ + { name: 'standard' }, + { name: 'translations', language: ['ar', 'zh'] }, + { name: 'accessible', accessibleFormat: ['x1', 'x0'] }, + ], + name: 'Test config', +}; + +const cascadeCondLang = { + cascadeGroups: ['translations'], + channel: 'LETTER', + channelType: 'secondary', + conditionalTemplates: [ + { language: 'ar', templateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09' }, + ], +}; - const cascadeCondLang = { - cascadeGroups: ['translations'], - channel: 'LETTER', - channelType: 'secondary', - conditionalTemplates: [ - { language: 'ar', templateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09' }, - ], - }; +const cascadeCondAcc = { + cascadeGroups: ['accessible'], + channel: 'LETTER', + channelType: 'secondary', + conditionalTemplates: [ + { + accessibleFormat: 'x1', + templateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09', + }, + ], +}; - const cascadeCondAcc = { - cascadeGroups: ['accessible'], - channel: 'LETTER', - channelType: 'secondary', - conditionalTemplates: [ - { - accessibleFormat: 'x1', - templateId: '90e46ece-4a3b-47bd-b781-f986b42a5a09', - }, - ], - }; +const baseCreated = { + ...baseInput, + clientId: 'client-1', + id: 'b9b6d56b-421e-462f-9ce5-3012e3fdb27f', + owner: 'CLIENT#client-1', + status: 'DRAFT', + createdAt: '2025-09-18T15:26:04.338Z', + createdBy: 'user-1', + updatedAt: '2025-09-18T15:26:04.338Z', + updatedBy: 'user-1', +}; +describe('CreateUpdateRoutingConfig schema', () => { test('valid minimal with defaultTemplateId cascade item', () => { - const res = $RoutingConfig.safeParse(base); + const res = $CreateUpdateRoutingConfig.safeParse(baseInput); expect(res.success).toBe(true); }); test('valid with translations conditional cascade item', () => { - const res = $RoutingConfig.safeParse({ - ...base, + const res = $CreateUpdateRoutingConfig.safeParse({ + ...baseInput, cascade: [cascadeCondLang], }); expect(res.success).toBe(true); }); test('valid with accessible conditional cascade item', () => { - const res = $RoutingConfig.safeParse({ - ...base, + const res = $CreateUpdateRoutingConfig.safeParse({ + ...baseInput, cascade: [cascadeCondAcc], }); expect(res.success).toBe(true); }); test('snapshot full error', () => { - const res = $RoutingConfig.safeParse({}); + const res = $CreateUpdateRoutingConfig.safeParse({}); expect(res.success).toBe(false); @@ -81,37 +86,40 @@ describe('RoutingConfig schema', () => { }); test('cascade must be nonempty', () => { - const res = $RoutingConfig.safeParse({ ...base, cascade: [] }); + const res = $CreateUpdateRoutingConfig.safeParse({ + ...baseInput, + cascade: [], + }); expect(res.success).toBe(false); }); test('cascadeGroupOverrides must be nonempty', () => { - const res = $RoutingConfig.safeParse({ - ...base, + const res = $CreateUpdateRoutingConfig.safeParse({ + ...baseInput, cascadeGroupOverrides: [], }); expect(res.success).toBe(false); }); test('translations override languages must be nonempty', () => { - const res = $RoutingConfig.safeParse({ - ...base, + const res = $CreateUpdateRoutingConfig.safeParse({ + ...baseInput, cascadeGroupOverrides: [{ name: 'translations', language: [] }], }); expect(res.success).toBe(false); }); test('accessible override accessibleFormat must be nonempty', () => { - const res = $RoutingConfig.safeParse({ - ...base, + const res = $CreateUpdateRoutingConfig.safeParse({ + ...baseInput, cascadeGroupOverrides: [{ name: 'accessible', accessibleFormat: [] }], }); expect(res.success).toBe(false); }); test('accessible cascade group must does accept "language"', () => { - const res = $RoutingConfig.safeParse({ - ...base, + const res = $CreateUpdateRoutingConfig.safeParse({ + ...baseInput, cascadeGroupOverrides: [ { name: 'accessible', accessibleFormat: ['x1'], language: ['ar'] }, ], @@ -120,8 +128,8 @@ describe('RoutingConfig schema', () => { }); test('translations cascade group does not accept "accessibleFormat"', () => { - const res = $RoutingConfig.safeParse({ - ...base, + const res = $CreateUpdateRoutingConfig.safeParse({ + ...baseInput, cascadeGroupOverrides: [ { name: 'translations', language: ['ar'], accessibleFormat: ['x1'] }, ], @@ -130,16 +138,16 @@ describe('RoutingConfig schema', () => { }); test('standard cascade group does not accept extra keys', () => { - const res = $RoutingConfig.safeParse({ - ...base, + const res = $CreateUpdateRoutingConfig.safeParse({ + ...baseInput, cascadeGroupOverrides: [{ name: 'standard', foo: 'bar' }], }); expect(res.success).toBe(false); }); test('CascadeItem with both defaultTemplateId and conditionalTemplates is invalid', () => { - const res = $RoutingConfig.safeParse({ - ...base, + const res = $CreateUpdateRoutingConfig.safeParse({ + ...baseInput, cascade: [ { ...cascadeCondLang, @@ -152,8 +160,8 @@ describe('RoutingConfig schema', () => { }); test('CascadeItem missing both defaultTemplateId and conditionalTemplates is invalid', () => { - const res = $RoutingConfig.safeParse({ - ...base, + const res = $CreateUpdateRoutingConfig.safeParse({ + ...baseInput, cascade: [ { cascadeGroups: ['standard'], @@ -165,17 +173,9 @@ describe('RoutingConfig schema', () => { expect(res.success).toBe(false); }); - test('invalid status', () => { - const res = $RoutingConfig.safeParse({ - ...base, - status: 'INVALID_STATUS', - }); - expect(res.success).toBe(false); - }); - test('invalid cascade channel', () => { - const res = $RoutingConfig.safeParse({ - ...base, + const res = $CreateUpdateRoutingConfig.safeParse({ + ...baseInput, cascade: [ { cascadeGroups: ['standard'], @@ -189,24 +189,24 @@ describe('RoutingConfig schema', () => { }); test('invalid cascade group language', () => { - const res = $RoutingConfig.safeParse({ - ...base, + const res = $CreateUpdateRoutingConfig.safeParse({ + ...baseInput, cascadeGroupOverrides: [{ name: 'translations', language: ['xx'] }], }); expect(res.success).toBe(false); }); test('invalid cascade group accessible format', () => { - const res = $RoutingConfig.safeParse({ - ...base, + const res = $CreateUpdateRoutingConfig.safeParse({ + ...baseInput, cascadeGroupOverrides: [{ name: 'accessible', accessibleFormat: ['xx'] }], }); expect(res.success).toBe(false); }); test('invalid cascade conditional language', () => { - const res = $RoutingConfig.safeParse({ - ...base, + const res = $CreateUpdateRoutingConfig.safeParse({ + ...baseInput, cascade: [ { cascadeGroups: ['translations'], @@ -225,8 +225,8 @@ describe('RoutingConfig schema', () => { }); test('invalid cascade conditional accessible format', () => { - const res = $RoutingConfig.safeParse({ - ...base, + const res = $CreateUpdateRoutingConfig.safeParse({ + ...baseInput, cascade: [ { cascadeGroups: ['accessible'], @@ -243,10 +243,33 @@ describe('RoutingConfig schema', () => { }); expect(res.success).toBe(false); }); +}); + +describe('RoutingConfig schema', () => { + test('valid minimal', () => { + const res = $RoutingConfig.safeParse(baseCreated); + expect(res.success).toBe(true); + }); + + test('snapshot full error', () => { + const res = $RoutingConfig.safeParse({}); + + expect(res.success).toBe(false); + + expect(res.error).toMatchSnapshot(); + }); + + test('invalid status', () => { + const res = $RoutingConfig.safeParse({ + ...baseCreated, + status: 'INVALID_STATUS', + }); + expect(res.success).toBe(false); + }); test('invalid if id not a uuid', () => { const res = $RoutingConfig.safeParse({ - ...base, + ...baseCreated, id: 'not-a-uuid', }); expect(res.success).toBe(false); diff --git a/lambdas/backend-client/src/schemas/routing-config.ts b/lambdas/backend-client/src/schemas/routing-config.ts index 9e585c2a6..2de6b9bea 100644 --- a/lambdas/backend-client/src/schemas/routing-config.ts +++ b/lambdas/backend-client/src/schemas/routing-config.ts @@ -13,6 +13,7 @@ import type { ChannelType, ConditionalTemplateAccessible, ConditionalTemplateLanguage, + CreateUpdateRoutingConfig, RoutingConfig, RoutingConfigStatus, RoutingConfigStatusActive, @@ -105,6 +106,16 @@ const $CascadeItem = schemaFor()( z.union([$CascadeItemWithConditional, $CascadeItemWithDefault]) ); +export const $CreateUpdateRoutingConfig = + schemaFor()( + z.object({ + campaignId: z.string(), + cascade: z.array($CascadeItem).nonempty(), + cascadeGroupOverrides: z.array($CascadeGroup).nonempty(), + name: z.string(), + }) + ); + const $RoutingConfigStatus = schemaFor()( z.enum(ROUTING_CONFIG_STATUS_LIST) ); @@ -116,17 +127,14 @@ const $RoutingConfigStatusActive = schemaFor()( export const $RoutingConfig = schemaFor()( z.object({ campaignId: z.string(), - clientId: z.string(), cascade: z.array($CascadeItem).nonempty(), cascadeGroupOverrides: z.array($CascadeGroup).nonempty(), + name: z.string(), + clientId: z.string(), id: z.uuidv4(), - owner: z.string(), status: $RoutingConfigStatus, - name: z.string(), createdAt: z.string(), - createdBy: z.string(), updatedAt: z.string(), - updatedBy: z.string(), }) ); diff --git a/lambdas/backend-client/src/types/generated/types.gen.ts b/lambdas/backend-client/src/types/generated/types.gen.ts index 6cbc16005..77b99969a 100644 --- a/lambdas/backend-client/src/types/generated/types.gen.ts +++ b/lambdas/backend-client/src/types/generated/types.gen.ts @@ -70,6 +70,9 @@ export type Channel = 'EMAIL' | 'LETTER' | 'NHSAPP' | 'SMS'; export type ChannelType = 'primary' | 'secondary'; export type ClientConfiguration = { + /** + * @deprecated + */ campaignId?: string; campaignIds?: Array; features: ClientFeatures; @@ -102,6 +105,13 @@ export type CountSuccess = { statusCode: number; }; +export type CreateUpdateRoutingConfig = { + campaignId: string; + cascade: Array; + cascadeGroupOverrides: Array; + name: string; +}; + export type CreateUpdateTemplate = BaseTemplate & (SmsProperties | EmailProperties | NhsAppProperties | UploadLetterProperties); @@ -184,13 +194,10 @@ export type RoutingConfig = { cascadeGroupOverrides: Array; clientId: string; createdAt: string; - createdBy: string; id: string; name: string; - owner: string; status: RoutingConfigStatus; updatedAt: string; - updatedBy: string; }; export type RoutingConfigStatus = RoutingConfigStatusActive | 'DELETED'; @@ -308,6 +315,36 @@ export type PostV1LetterTemplateResponses = { export type PostV1LetterTemplateResponse = PostV1LetterTemplateResponses[keyof PostV1LetterTemplateResponses]; +export type PostV1RoutingConfigurationData = { + /** + * Routing configuration to create + */ + body: CreateUpdateRoutingConfig; + path?: never; + query?: never; + url: '/v1/routing-configuration'; +}; + +export type PostV1RoutingConfigurationErrors = { + /** + * Error + */ + default: Failure; +}; + +export type PostV1RoutingConfigurationError = + PostV1RoutingConfigurationErrors[keyof PostV1RoutingConfigurationErrors]; + +export type PostV1RoutingConfigurationResponses = { + /** + * 201 response + */ + 201: RoutingConfigSuccess; +}; + +export type PostV1RoutingConfigurationResponse = + PostV1RoutingConfigurationResponses[keyof PostV1RoutingConfigurationResponses]; + export type GetV1RoutingConfigurationByRoutingConfigIdData = { body?: never; path: { diff --git a/scripts/sandbox_auth.sh b/scripts/sandbox_auth.sh index 62b76020f..57396b23e 100755 --- a/scripts/sandbox_auth.sh +++ b/scripts/sandbox_auth.sh @@ -15,6 +15,7 @@ password=$2 cognito_user_pool_id=$(jq -r .cognito_user_pool_id.value $root_dir/sandbox_tf_outputs.json) cognito_user_pool_client_id=$(jq -r .cognito_user_pool_client_id.value $root_dir/sandbox_tf_outputs.json) +client_ssm_path_prefix=$(jq -r .client_ssm_path_prefix.value $root_dir/sandbox_tf_outputs.json) set +e # if the user or client doesn't exist, we expect these commands to fail get_user_command_output=$(aws cognito-idp admin-get-user --user-pool-id "$cognito_user_pool_id" --username "$email" 2>&1) @@ -36,7 +37,28 @@ if [[ "$get_user_command_exit_code" -ne 0 ]]; then temp_password=$(gen_temp_password) - read -p "Enter a Notify Client ID for this user: " notify_client_id + read -p "Enter a Notify Client ID for this user, or press enter to generate one: " notify_client_id + + if [ -z "$notify_client_id" ]; then + notify_client_id=$(uuidgen | tr '[:upper:]' '[:lower:]') + fi + + echo "Generated Client ID: $notify_client_id" + + client_config_param_name="$client_ssm_path_prefix/$notify_client_id" + + client_config_param_value="{ "campaignId": "campaign", "features": { "proofing": true } }" + + if aws ssm get-parameter --name "$client_config_param_name" --with-decryption >/dev/null 2>&1; then + echo "Client config parameter already exists: $client_config_param_name" + else + aws ssm put-parameter \ + --name "$client_config_param_name" \ + --value "$client_config_param_value" \ + --type String + + echo "Created client config parameter: $client_config_param_name" + fi aws cognito-idp admin-create-user \ --user-pool-id "${cognito_user_pool_id}" \ diff --git a/tests/test-team/helpers/db/routing-config-storage-helper.ts b/tests/test-team/helpers/db/routing-config-storage-helper.ts index 57e427dcc..7290bf8fc 100644 --- a/tests/test-team/helpers/db/routing-config-storage-helper.ts +++ b/tests/test-team/helpers/db/routing-config-storage-helper.ts @@ -5,7 +5,7 @@ import { } from '@aws-sdk/lib-dynamodb'; import type { RoutingConfig } from 'nhs-notify-backend-client'; -type RoutingConfigKey = Pick; +type RoutingConfigKey = { id: string; clientId: string }; export class RoutingConfigStorageHelper { private readonly dynamo: DynamoDBDocumentClient = DynamoDBDocumentClient.from( @@ -14,6 +14,8 @@ export class RoutingConfigStorageHelper { private seedData: RoutingConfig[] = []; + private adHocRoutingConfigKeys: RoutingConfigKey[] = []; + /** * Seed a load of routing configs into the database */ @@ -46,9 +48,9 @@ export class RoutingConfigStorageHelper { */ public async deleteSeeded() { await this.delete( - this.seedData.map(({ id, owner }) => ({ + this.seedData.map(({ id, clientId }) => ({ id, - owner, + clientId, })) ); this.seedData = []; @@ -63,11 +65,11 @@ export class RoutingConfigStorageHelper { new BatchWriteCommand({ RequestItems: { [process.env.ROUTING_CONFIG_TABLE_NAME]: chunk.map( - ({ id, owner }) => ({ + ({ id, clientId }) => ({ DeleteRequest: { Key: { id, - owner, + owner: this.clientOwnerKey(clientId), }, }, }) @@ -79,6 +81,21 @@ export class RoutingConfigStorageHelper { ); } + /** + * Stores references to routing configs created in tests (not via seeding) + */ + public addAdHocRoutingConfigKey(key: RoutingConfigKey) { + this.adHocRoutingConfigKeys.push(key); + } + + /** + * Delete routing configs referenced by calls to addAdHocRoutingConfigKey from database + */ + async deleteAdHocRoutingConfigs() { + await this.delete(this.adHocRoutingConfigKeys); + this.adHocRoutingConfigKeys = []; + } + /** * Breaks a list into chunks of upto 25 items */ @@ -91,4 +108,8 @@ export class RoutingConfigStorageHelper { return chunks; } + + private clientOwnerKey(clientId: string) { + return `CLIENT#${clientId}`; + } } diff --git a/tests/test-team/helpers/factories/routing-config-factory.ts b/tests/test-team/helpers/factories/routing-config-factory.ts index 14056dbd0..0ab556795 100644 --- a/tests/test-team/helpers/factories/routing-config-factory.ts +++ b/tests/test-team/helpers/factories/routing-config-factory.ts @@ -1,14 +1,20 @@ import { randomUUID } from 'node:crypto'; -import { RoutingConfig } from 'nhs-notify-backend-client'; +import { + CreateUpdateRoutingConfig, + RoutingConfig, +} from 'nhs-notify-backend-client'; +import type { + FactoryRoutingConfig, + RoutingConfigDbEntry, +} from '../../helpers/types'; export const RoutingConfigFactory = { create( - routingConfig: Partial & Pick - ): RoutingConfig { - return { - id: randomUUID(), + user: { userId: string; clientId: string }, + routingConfig: Partial = {} + ): FactoryRoutingConfig { + const apiPayload: CreateUpdateRoutingConfig = { campaignId: 'campaign-1', - clientId: 'client-1', cascade: [ { cascadeGroups: ['standard'], @@ -18,13 +24,30 @@ export const RoutingConfigFactory = { }, ], cascadeGroupOverrides: [{ name: 'standard' }], - updatedBy: 'user-1', - status: 'DRAFT', name: 'Test config', + ...routingConfig, + }; + + const apiResponse: RoutingConfig = { + id: randomUUID(), + clientId: user.clientId, + status: 'DRAFT', createdAt: new Date().toISOString(), - createdBy: 'user-1', updatedAt: new Date().toISOString(), - ...routingConfig, + ...apiPayload, + }; + + const dbEntry: RoutingConfigDbEntry = { + owner: `CLIENT#${user.clientId}`, + createdBy: user.userId, + updatedBy: user.userId, + ...apiResponse, + }; + + return { + apiPayload, + apiResponse, + dbEntry, }; }, }; diff --git a/tests/test-team/helpers/types.ts b/tests/test-team/helpers/types.ts index fa0e5feeb..92114596d 100644 --- a/tests/test-team/helpers/types.ts +++ b/tests/test-team/helpers/types.ts @@ -1,3 +1,8 @@ +import type { + CreateUpdateRoutingConfig, + RoutingConfig, +} from 'nhs-notify-backend-client'; + export const templateTypeDisplayMappings: Record = { NHS_APP: 'NHS App message', SMS: 'Text message (SMS)', @@ -63,3 +68,15 @@ export type Template = TypeSpecificProperties & { version: number; proofingEnabled?: boolean; }; + +export type RoutingConfigDbEntry = RoutingConfig & { + owner: string; + updatedBy: string; + createdBy: string; +}; + +export type FactoryRoutingConfig = { + apiPayload: CreateUpdateRoutingConfig; + apiResponse: RoutingConfig; + dbEntry: RoutingConfigDbEntry; +}; diff --git a/tests/test-team/template-mgmt-api-tests/count-routing-configs.api.spec.ts b/tests/test-team/template-mgmt-api-tests/count-routing-configs.api.spec.ts index aecb5efb0..fc0600a41 100644 --- a/tests/test-team/template-mgmt-api-tests/count-routing-configs.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/count-routing-configs.api.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import type { RoutingConfig } from 'nhs-notify-backend-client'; import { createAuthHelper, type TestUser, @@ -7,6 +6,7 @@ import { } from '../helpers/auth/cognito-auth-helper'; import { RoutingConfigStorageHelper } from '../helpers/db/routing-config-storage-helper'; import { RoutingConfigFactory } from '../helpers/factories/routing-config-factory'; +import type { FactoryRoutingConfig } from 'helpers/types'; test.describe('GET /v1/routing-configurations/count', () => { const authHelper = createAuthHelper(); @@ -14,43 +14,29 @@ test.describe('GET /v1/routing-configurations/count', () => { let user1: TestUser; let user2: TestUser; let userSharedClient: TestUser; - let draftRoutingConfig: RoutingConfig; - let completedRoutingConfig: RoutingConfig; - let deletedRoutingConfig: RoutingConfig; + let draftRoutingConfig: FactoryRoutingConfig; + let completedRoutingConfig: FactoryRoutingConfig; + let deletedRoutingConfig: FactoryRoutingConfig; test.beforeAll(async () => { user1 = await authHelper.getTestUser(testUsers.User1.userId); user2 = await authHelper.getTestUser(testUsers.User2.userId); userSharedClient = await authHelper.getTestUser(testUsers.User7.userId); - draftRoutingConfig = RoutingConfigFactory.create({ - owner: user1.clientId, - clientId: user1.clientId, - status: 'DRAFT', - createdBy: user1.userId, - updatedBy: user1.userId, - }); + draftRoutingConfig = RoutingConfigFactory.create(user1); - completedRoutingConfig = RoutingConfigFactory.create({ - owner: user1.clientId, - clientId: user1.clientId, + completedRoutingConfig = RoutingConfigFactory.create(user1, { status: 'COMPLETED', - createdBy: user1.userId, - updatedBy: user1.userId, }); - deletedRoutingConfig = RoutingConfigFactory.create({ - owner: user1.clientId, - clientId: user1.clientId, + deletedRoutingConfig = RoutingConfigFactory.create(user1, { status: 'DELETED', - createdBy: user1.userId, - updatedBy: user1.userId, }); await storageHelper.seed([ - draftRoutingConfig, - completedRoutingConfig, - deletedRoutingConfig, + draftRoutingConfig.dbEntry, + completedRoutingConfig.dbEntry, + deletedRoutingConfig.dbEntry, ]); }); diff --git a/tests/test-team/template-mgmt-api-tests/create-routing-configuration.api.spec.ts b/tests/test-team/template-mgmt-api-tests/create-routing-configuration.api.spec.ts new file mode 100644 index 000000000..995a3302c --- /dev/null +++ b/tests/test-team/template-mgmt-api-tests/create-routing-configuration.api.spec.ts @@ -0,0 +1,157 @@ +import { test, expect } from '@playwright/test'; +import { + createAuthHelper, + type TestUser, + testUsers, +} from '../helpers/auth/cognito-auth-helper'; +import { + isoDateRegExp, + uuidRegExp, +} from 'nhs-notify-web-template-management-test-helper-utils'; +import { RoutingConfigStorageHelper } from '../helpers/db/routing-config-storage-helper'; +import { RoutingConfigFactory } from '../helpers/factories/routing-config-factory'; + +test.describe('POST /v1/routing-configuration', () => { + const authHelper = createAuthHelper(); + const storageHelper = new RoutingConfigStorageHelper(); + let user1: TestUser; + + test.beforeAll(async () => { + user1 = await authHelper.getTestUser(testUsers.User1.userId); + }); + + test.afterAll(async () => { + await storageHelper.deleteAdHocRoutingConfigs(); + }); + + test('returns 201 if routing config input is valid', async ({ request }) => { + const payload = RoutingConfigFactory.create(user1).apiPayload; + + const start = new Date(); + + const response = await request.post( + `${process.env.API_BASE_URL}/v1/routing-configuration`, + { + headers: { + Authorization: await user1.getAccessToken(), + }, + data: payload, + } + ); + + expect(response.status()).toBe(201); + + const created = await response.json(); + + storageHelper.addAdHocRoutingConfigKey({ + id: created.data.id, + clientId: user1.clientId, + }); + + expect(created).toEqual({ + statusCode: 201, + data: { + clientId: user1.clientId, + campaignId: payload.campaignId, + cascade: payload.cascade, + cascadeGroupOverrides: payload.cascadeGroupOverrides, + createdAt: expect.stringMatching(isoDateRegExp), + name: payload.name, + id: expect.stringMatching(uuidRegExp), + status: 'DRAFT', + updatedAt: expect.stringMatching(isoDateRegExp), + }, + }); + + expect(created.data.createdAt).toBeDateRoughlyBetween([start, new Date()]); + expect(created.data.createdAt).toEqual(created.data.updatedAt); + }); + + test('returns 401 if no auth token', async ({ request }) => { + const response = await request.post( + `${process.env.API_BASE_URL}/v1/routing-configuration` + ); + expect(response.status()).toBe(401); + expect(await response.json()).toEqual({ + message: 'Unauthorized', + }); + }); + + test('returns 400 if no body on request', async ({ request }) => { + const response = await request.post( + `${process.env.API_BASE_URL}/v1/routing-configuration`, + { + headers: { + Authorization: await user1.getAccessToken(), + }, + } + ); + + expect(response.status()).toBe(400); + + expect(await response.json()).toEqual({ + statusCode: 400, + technicalMessage: 'Request failed validation', + details: { + campaignId: 'Invalid input: expected string, received undefined', + cascade: 'Invalid input: expected array, received undefined', + cascadeGroupOverrides: + 'Invalid input: expected array, received undefined', + name: 'Invalid input: expected string, received undefined', + }, + }); + }); + + test('returns 400 if routing config has invalid field (name)', async ({ + request, + }) => { + const response = await request.post( + `${process.env.API_BASE_URL}/v1/routing-configuration`, + { + headers: { + Authorization: await user1.getAccessToken(), + }, + data: RoutingConfigFactory.create(user1, { + name: 700 as unknown as string, + }).apiPayload, + } + ); + + expect(response.status()).toBe(400); + + expect(await response.json()).toEqual({ + statusCode: 400, + technicalMessage: 'Request failed validation', + details: { + name: 'Invalid input: expected string, received number', + }, + }); + }); + + test('ignores status if given - routing config cannot be completed at create time', async ({ + request, + }) => { + const response = await request.post( + `${process.env.API_BASE_URL}/v1/routing-configuration`, + { + headers: { + Authorization: await user1.getAccessToken(), + }, + data: RoutingConfigFactory.create(user1, { + status: 'COMPLETED', + }).apiPayload, + } + ); + + expect(response.status()).toBe(201); + + const created = await response.json(); + + storageHelper.addAdHocRoutingConfigKey({ + id: created.data.id, + clientId: user1.clientId, + }); + + expect(created.data.status).toEqual('DRAFT'); + }); +}); diff --git a/tests/test-team/template-mgmt-api-tests/get-routing-config.api.spec.ts b/tests/test-team/template-mgmt-api-tests/get-routing-config.api.spec.ts index 35ecd8e37..3c8224ec3 100644 --- a/tests/test-team/template-mgmt-api-tests/get-routing-config.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/get-routing-config.api.spec.ts @@ -1,6 +1,5 @@ import { randomUUID } from 'node:crypto'; import { test, expect } from '@playwright/test'; -import type { RoutingConfig } from 'nhs-notify-backend-client'; import { createAuthHelper, type TestUser, @@ -8,6 +7,7 @@ import { } from '../helpers/auth/cognito-auth-helper'; import { RoutingConfigStorageHelper } from '../helpers/db/routing-config-storage-helper'; import { RoutingConfigFactory } from '../helpers/factories/routing-config-factory'; +import type { FactoryRoutingConfig } from 'helpers/types'; test.describe('GET /v1/routing-configuration/:routingConfigId', () => { const authHelper = createAuthHelper(); @@ -15,24 +15,24 @@ test.describe('GET /v1/routing-configuration/:routingConfigId', () => { let user1: TestUser; let user2: TestUser; let userSharedClient: TestUser; - let routingConfig: RoutingConfig; - let deletedRoutingConfig: RoutingConfig; + let routingConfig: FactoryRoutingConfig; + let deletedRoutingConfig: FactoryRoutingConfig; test.beforeAll(async () => { user1 = await authHelper.getTestUser(testUsers.User1.userId); user2 = await authHelper.getTestUser(testUsers.User2.userId); userSharedClient = await authHelper.getTestUser(testUsers.User7.userId); - routingConfig = RoutingConfigFactory.create({ - owner: user1.clientId, - }); + routingConfig = RoutingConfigFactory.create(user1); - deletedRoutingConfig = RoutingConfigFactory.create({ - owner: user1.clientId, + deletedRoutingConfig = RoutingConfigFactory.create(user1, { status: 'DELETED', }); - await storageHelper.seed([routingConfig, deletedRoutingConfig]); + await storageHelper.seed([ + routingConfig.dbEntry, + deletedRoutingConfig.dbEntry, + ]); }); test.afterAll(async () => { @@ -54,7 +54,7 @@ test.describe('GET /v1/routing-configuration/:routingConfigId', () => { }) => { // exercise: make the GET request to retrieve the routing config const response = await request.get( - `${process.env.API_BASE_URL}/v1/routing-configuration/${routingConfig.id}`, + `${process.env.API_BASE_URL}/v1/routing-configuration/${routingConfig.dbEntry.id}`, { headers: { Authorization: await user1.getAccessToken(), @@ -66,7 +66,7 @@ test.describe('GET /v1/routing-configuration/:routingConfigId', () => { expect(response.status()).toBe(200); expect(await response.json()).toEqual({ statusCode: 200, - data: routingConfig, + data: routingConfig.apiResponse, }); }); @@ -92,7 +92,7 @@ test.describe('GET /v1/routing-configuration/:routingConfigId', () => { }) => { // exercise: make the GET request to retrieve the routing config as user2 const response = await request.get( - `${process.env.API_BASE_URL}/v1/routing-configuration/${routingConfig.id}`, + `${process.env.API_BASE_URL}/v1/routing-configuration/${routingConfig.dbEntry.id}`, { headers: { Authorization: await user2.getAccessToken(), @@ -113,7 +113,7 @@ test.describe('GET /v1/routing-configuration/:routingConfigId', () => { }) => { // exercise: make the GET request to retrieve the deleted routing config const response = await request.get( - `${process.env.API_BASE_URL}/v1/routing-configuration/${deletedRoutingConfig.id}`, + `${process.env.API_BASE_URL}/v1/routing-configuration/${deletedRoutingConfig.dbEntry.id}`, { headers: { Authorization: await user1.getAccessToken(), @@ -133,7 +133,7 @@ test.describe('GET /v1/routing-configuration/:routingConfigId', () => { request, }) => { const response = await request.get( - `${process.env.API_BASE_URL}/v1/routing-configuration/${routingConfig.id}`, + `${process.env.API_BASE_URL}/v1/routing-configuration/${routingConfig.dbEntry.id}`, { headers: { Authorization: await userSharedClient.getAccessToken(), @@ -144,7 +144,7 @@ test.describe('GET /v1/routing-configuration/:routingConfigId', () => { expect(response.status()).toBe(200); expect(await response.json()).toEqual({ statusCode: 200, - data: routingConfig, + data: routingConfig.apiResponse, }); }); }); diff --git a/tests/test-team/template-mgmt-api-tests/list-routing-configs.api.spec.ts b/tests/test-team/template-mgmt-api-tests/list-routing-configs.api.spec.ts index 3501d44bc..0651ab32c 100644 --- a/tests/test-team/template-mgmt-api-tests/list-routing-configs.api.spec.ts +++ b/tests/test-team/template-mgmt-api-tests/list-routing-configs.api.spec.ts @@ -1,5 +1,4 @@ import { test, expect } from '@playwright/test'; -import type { RoutingConfig } from 'nhs-notify-backend-client'; import { createAuthHelper, type TestUser, @@ -7,6 +6,7 @@ import { } from '../helpers/auth/cognito-auth-helper'; import { RoutingConfigStorageHelper } from '../helpers/db/routing-config-storage-helper'; import { RoutingConfigFactory } from '../helpers/factories/routing-config-factory'; +import type { FactoryRoutingConfig } from 'helpers/types'; test.describe('GET /v1/routing-configurations', () => { const authHelper = createAuthHelper(); @@ -14,43 +14,34 @@ test.describe('GET /v1/routing-configurations', () => { let user1: TestUser; let user2: TestUser; let userSharedClient: TestUser; - let draftRoutingConfig: RoutingConfig; - let completedRoutingConfig: RoutingConfig; - let deletedRoutingConfig: RoutingConfig; + let draftRoutingConfig: FactoryRoutingConfig; + let completedRoutingConfig: FactoryRoutingConfig; + let deletedRoutingConfig: FactoryRoutingConfig; test.beforeAll(async () => { user1 = await authHelper.getTestUser(testUsers.User1.userId); user2 = await authHelper.getTestUser(testUsers.User2.userId); userSharedClient = await authHelper.getTestUser(testUsers.User7.userId); - draftRoutingConfig = RoutingConfigFactory.create({ - owner: user1.clientId, + draftRoutingConfig = RoutingConfigFactory.create(user1, { clientId: user1.clientId, status: 'DRAFT', - createdBy: user1.userId, - updatedBy: user1.userId, }); - completedRoutingConfig = RoutingConfigFactory.create({ - owner: user1.clientId, + completedRoutingConfig = RoutingConfigFactory.create(user1, { clientId: user1.clientId, status: 'COMPLETED', - createdBy: user1.userId, - updatedBy: user1.userId, }); - deletedRoutingConfig = RoutingConfigFactory.create({ - owner: user1.clientId, + deletedRoutingConfig = RoutingConfigFactory.create(user1, { clientId: user1.clientId, status: 'DELETED', - createdBy: user1.userId, - updatedBy: user1.userId, }); await storageHelper.seed([ - draftRoutingConfig, - completedRoutingConfig, - deletedRoutingConfig, + draftRoutingConfig.dbEntry, + completedRoutingConfig.dbEntry, + deletedRoutingConfig.dbEntry, ]); }); @@ -89,8 +80,8 @@ test.describe('GET /v1/routing-configurations', () => { expect(body).toEqual({ statusCode: 200, data: expect.arrayContaining([ - draftRoutingConfig, - completedRoutingConfig, + draftRoutingConfig.apiResponse, + completedRoutingConfig.apiResponse, ]), }); @@ -142,8 +133,8 @@ test.describe('GET /v1/routing-configurations', () => { expect(body).toEqual({ statusCode: 200, data: expect.arrayContaining([ - draftRoutingConfig, - completedRoutingConfig, + draftRoutingConfig.apiResponse, + completedRoutingConfig.apiResponse, ]), }); @@ -169,7 +160,7 @@ test.describe('GET /v1/routing-configurations', () => { expect(body).toEqual({ statusCode: 200, - data: [draftRoutingConfig], + data: [draftRoutingConfig.apiResponse], }); }); @@ -192,7 +183,7 @@ test.describe('GET /v1/routing-configurations', () => { expect(body).toEqual({ statusCode: 200, - data: [completedRoutingConfig], + data: [completedRoutingConfig.apiResponse], }); });