Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(DTFS-7049): added POST /emails endpoint for sending emails using GOV.UK Notify service #820

Merged
merged 32 commits into from
May 31, 2024
Merged
Changes from 1 commit
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
f572563
feat(DTFS2-7049): adding new dependency package notifications-node-cl…
audrius-vaitonis Apr 16, 2024
346b81b
feat(DTFS2-7049): fix lint dependency
audrius-vaitonis Apr 16, 2024
e460153
feat(DTFS2-7049): a lot of functionality for sending emails and tests
audrius-vaitonis Apr 23, 2024
60a8504
feat(DTFS2-7049): constants, cleanup, refactoring
audrius-vaitonis Apr 25, 2024
12a472b
feat(DTFS2-7049): clean up
audrius-vaitonis Apr 25, 2024
6a87b26
feat(DTFS2-7049): test email in POST body using new email helper, imp…
audrius-vaitonis Apr 29, 2024
d3b858f
Merge remote-tracking branch 'origin/main' into feat/DTFS2-7049/new-e…
audrius-vaitonis Apr 29, 2024
45a9885
feat(DTFS2-7049): refactor testing helpers, other improvements for PR
audrius-vaitonis Apr 30, 2024
692dba6
Merge remote-tracking branch 'origin/main' into feat/DTFS2-7049/new-e…
audrius-vaitonis May 13, 2024
dcc8255
feat(DTFS2-7049): extra tests for edge cases, when request body is empty
audrius-vaitonis May 14, 2024
071d164
feat(DTFS2-7049): updating api snapshot
audrius-vaitonis May 14, 2024
b2ca686
feat(DTFS2-7049): removing dependency 'escape-string-regexp'
audrius-vaitonis May 17, 2024
7fb190f
feat(DTFS2-7049): changed POST mail response description
audrius-vaitonis May 17, 2024
804327b
feat(DTFS2-7049): added 500 exception to GOVUK Notify service
audrius-vaitonis May 17, 2024
b0daef4
feat(DTFS2-7049): tests improvements, added logger for Gov.uk Notify …
audrius-vaitonis May 17, 2024
31f1f65
feat(DTFS2-7049): added exception types to function signatures
audrius-vaitonis May 17, 2024
eb238c6
feat(DTFS2-7049): various PR fixes
audrius-vaitonis May 17, 2024
c8b6de0
feat(DTFS2-7049): added jsdoc to describe function exception list usi…
audrius-vaitonis May 20, 2024
4ad57fa
feat(DTFS2-7049): added type and return type to test helper prepareMo…
audrius-vaitonis May 20, 2024
a9ea85a
feat(DTFS2-7049): unit test for test helper prepareModifiedRequest
audrius-vaitonis May 20, 2024
7118143
Merge remote-tracking branch 'origin/main' into feat/DTFS2-7049/new-e…
audrius-vaitonis May 20, 2024
e44213f
Apply suggestions from code review
audrius-vaitonis May 22, 2024
e0e099e
feat(DTFS2-7049): fixing PR issues
audrius-vaitonis May 24, 2024
71e44ff
feat(DTFS2-7049): fixed tests caused by removal of global transformer…
audrius-vaitonis May 24, 2024
3bb8d71
feat(DTFS2-7049): removed BadRequestException tests moving payload to…
audrius-vaitonis May 24, 2024
8acb0e9
Merge remote-tracking branch 'origin/main' into feat/DTFS2-7049/new-e…
audrius-vaitonis May 24, 2024
c14fa49
feat(DTFS2-7049): test unknown Gov.UK Notify responses
audrius-vaitonis May 31, 2024
c14c735
Apply suggestions from code review
audrius-vaitonis May 31, 2024
9e2d28b
Merge remote-tracking branch 'origin/main' into feat/DTFS2-7049/new-e…
audrius-vaitonis May 31, 2024
389ddf5
Merge branch 'feat/DTFS2-7049/new-external-gov-notify-api' of https:/…
audrius-vaitonis May 31, 2024
86b5b4d
feat(DTFS2-7049): return error message as string instead of array
audrius-vaitonis May 31, 2024
0e61f53
Merge remote-tracking branch 'origin/main' into feat/DTFS2-7049/new-e…
audrius-vaitonis May 31, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Prev Previous commit
Next Next commit
feat(DTFS2-7049): a lot of functionality for sending emails and tests
  • Loading branch information
audrius-vaitonis committed Apr 23, 2024
commit e460153a7382929399fc67e073ab31029d10fdff
3 changes: 3 additions & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
@@ -18,6 +18,7 @@
"acbs",
"APIM",
"azurecr",
"Bloggs",
"BBALIBOR",
"CILC",
"codacy",
@@ -33,6 +34,7 @@
"ESRA",
"ESTR",
"EWCS",
"Govuk",
"huskyrc",
"isready",
"Informatica",
@@ -45,6 +47,7 @@
"pinojs",
"snet",
"szenius",
"tmpl",
"typeorm",
"Typeorm",
"typescript",
5 changes: 4 additions & 1 deletion .eslintrc.json
Original file line number Diff line number Diff line change
@@ -110,7 +110,10 @@
"consistent-return": "off",
"no-unused-vars": "off",
"unused-imports/no-unused-imports": "error",
"unused-imports/no-unused-vars": "error",
"unused-imports/no-unused-vars": [
"error",
{ "vars": "all", "varsIgnorePattern": "^_", "args": "after-used", "argsIgnorePattern": "^_" }
],
"@typescript-eslint/no-unused-vars": "off",
"@typescript-eslint/ban-ts-comment": "off",
"@typescript-eslint/no-explicit-any": "off",
1 change: 1 addition & 0 deletions jest.config.ts
Original file line number Diff line number Diff line change
@@ -12,6 +12,7 @@ const defaultSettings = {
'@ukef/config/(.*)': '<rootDir>/../src/config/$1',
'@ukef/database/(.*)': '<rootDir>/../src/modules/database/$1',
'@ukef/helpers/(.*)': '<rootDir>/../src/helpers/$1',
'@ukef/helper-modules/(.*)': '<rootDir>/../src/helper-modules/$1',
'@ukef/modules/(.*)': '<rootDir>/../src/modules/$1',
'@ukef/auth/(.*)': '<rootDir>/../src/modules/auth/$1',
'@ukef/(.*)': '<rootDir>/../src/$1',
1 change: 1 addition & 0 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
@@ -77,6 +77,7 @@
"@typescript-eslint/parser": "^7.6.0",
"chance": "^1.1.11",
"cspell": "^8.3.2",
"escape-string-regexp": "^4.0.0",
"eslint": "^8.56.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^17.1.0",
30 changes: 30 additions & 0 deletions src/helper-modules/govuk-notify/dto/post-email-request.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsEmail, IsNotEmpty, IsObject, IsOptional, IsString, MaxLength, MinLength } from 'class-validator';

export class PostEmailRequestItemDto {
@IsString()
@IsNotEmpty()
@MinLength(1)
@MaxLength(40)
@ApiProperty({ example: 'tmpl1234-1234-5678-9012-abcd12345678' })
readonly templateId: string;

@IsEmail()
@IsNotEmpty()
@MinLength(7)
@MaxLength(60)
@ApiProperty({ example: 'john.tester@example.com', description: 'Email address to send this email' })
readonly sendToEmailAddress: string;

@IsObject()
@IsOptional()
@ApiProperty({ example: { firstName: 'John' }, description: 'All variables for email template' })
readonly personalisation?: { [key: string]: string | number };

@IsString()
@IsOptional()
@MinLength(1)
@MaxLength(60)
@ApiProperty({ example: 'tmpl1234-1234-5678-9012-abcd12345678-1713272155576' })
readonly reference?: string;
}
89 changes: 89 additions & 0 deletions src/helper-modules/govuk-notify/dto/post-email-response.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
import { ApiProperty } from '@nestjs/swagger';

export class PostEmailResponseDataContent {
@ApiProperty({
example:
'Dear John Smith,\r\n\r\nThe status of your MIA for EuroStar has been updated.\r\n\r\n* Your bank reference: EuroStar bridge\r\n* Current status: Acknowledged\r\n* Previous status: Submitted\r\n* Updated by: Joe Bloggs (Joe.Bloggs@example.com)\r\n\r\nSign in to our service for more information: \r\nhttps://www.test.service.gov.uk/\r\n\r\nWith regards,\r\n\r\nThe Digital Trade Finance Service team\r\n\r\nEmail: test@test.gov.uk\r\nPhone: +44 (0)202 123 4567\r\nOpening times: Monday to Friday, 9am to 5pm (excluding public holidays)',
description: 'Email text',
})
public body: string;

@ApiProperty({
example: 'test@notifications.service.gov.uk',
description: 'Email is sent from this address',
})
public from_email: string;

@ApiProperty({
example: 'Status update: EuroStar bridge',
description: 'Email subject line',
})
public subject: string;

@ApiProperty({
example: null,
description: "we don't use unsubscribe links",
})
public unsubscribe_link: string | null;
}

export class PostEmailResponseDataTemplate {
@ApiProperty({
example: 'tmpl1234-1234-5678-9012-abcd12345678',
description: "Notify's template id",
})
public id: string;

@ApiProperty({
example: 'https://api.notifications.service.gov.uk/services/abc12345-a123-4567-8901-123456789012/templates/tmpl1234-1234-5678-9012-abcd12345678',
description: 'URL to get more information about template',
})
public uri: string;

@ApiProperty({
example: 5,
description: 'Template version',
})
public version: number;
}

export class PostEmailResponseData {
@ApiProperty({ type: PostEmailResponseDataContent })
public content: PostEmailResponseDataContent;

@ApiProperty({
example: 'efd12345-1234-5678-9012-ee123456789f',
description: "Notify's id for the status receipts",
})
public id: string;

@ApiProperty({
example: 'tmpl1234-1234-5678-9012-abcd12345678-1713346533467',
description: 'Reference id you provided for this transaction',
})
public reference: string;

@ApiProperty({
example: null,
description: "We don't schedule emails",
})
public scheduled_for: string | null;

@ApiProperty({ type: PostEmailResponseDataTemplate })
public template: PostEmailResponseDataTemplate;

@ApiProperty({
example: 'https://api.notifications.service.gov.uk/v2/notifications/efd12345-1234-5678-9012-ee123456789f',
description: 'API location to get more information about this transaction',
})
public uri: string;
}
export class PostEmailResponseDto {
@ApiProperty({
example: 201,
description: 'Http status code',
})
status: number;
@ApiProperty({ type: PostEmailResponseData })
data: PostEmailResponseData;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"content": {
"body": "Dear John Smith,\r\n\r\nThe status of your MIA for EuroStar has been updated.\r\n\r\n* Your bank reference: EuroStar bridge\r\n* Current status: Acknowledged\r\n* Previous status: Submitted\r\n* Updated by: Joe Bloggs (Joe.Bloggs@example.com)\r\n\r\nSign in to our service for more information: \r\nhttps://www.test.service.gov.uk/\r\n\r\nWith regards,\r\n\r\nThe Digital Trade Finance Service team\r\n\r\nEmail: test@test.gov.uk\r\nPhone: +44 (0)202 123 4567\r\nOpening times: Monday to Friday, 9am to 5pm (excluding public holidays)",
"from_email": "test@notifications.service.gov.uk",
"subject": "Status update: EuroStar bridge",
"unsubscribe_link": null
},
"id": "efd12345-1234-5678-9012-ee123456789f",
"reference": "tmpl1234-1234-5678-9012-abcd12345678-1713346533467",
"scheduled_for": null,
"template": {
"id": "tmpl1234-1234-5678-9012-abcd12345678",
"uri": "https://api.notifications.service.gov.uk/services/abc12345-a123-4567-8901-123456789012/templates/tmpl1234-1234-5678-9012-abcd12345678",
"version": 24
},
"uri": "https://api.notifications.service.gov.uk/v2/notifications/efd12345-1234-5678-9012-ee123456789f"
}
10 changes: 10 additions & 0 deletions src/helper-modules/govuk-notify/govuk-notify.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';

import { GovukNotifyService } from './govuk-notify.service';

@Module({
imports: [],
providers: [GovukNotifyService],
exports: [GovukNotifyService],
})
export class GovukNotifyModule {}
129 changes: 129 additions & 0 deletions src/helper-modules/govuk-notify/govuk-notify.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator';
import expectedResponse = require('./examples/example-response-for-send-email.json');
import { BadRequestException, ForbiddenException, UnauthorizedException, UnprocessableEntityException } from '@nestjs/common';
import { AxiosError, AxiosResponse } from 'axios';
import { NotifyClient } from 'notifications-node-client';

import { GovukNotifyService } from './govuk-notify.service';
jest.mock('notifications-node-client');

describe('GovukNotifyService', () => {
const valueGenerator = new RandomValueGenerator();

let service: GovukNotifyService;

let sendEmailMethodMock;

const govUkNotifyKey = valueGenerator.string({ length: 10 });
const sendToEmailAddress = valueGenerator.email();
const templateId = valueGenerator.string({ length: 10 });
const errorMessage = valueGenerator.sentence();
const personalisation = {
firstName: valueGenerator.word(),
surname: valueGenerator.word(),
supplierName: valueGenerator.word(),
};

beforeEach(() => {
jest.resetAllMocks();
sendEmailMethodMock = jest.spyOn(NotifyClient.prototype, 'sendEmail').mockImplementation(() => Promise.resolve({ status: 201, data: expectedResponse }));
service = new GovukNotifyService();
});

describe('sendEmail', () => {
const generateNotifyError = (status: number, message: string) => {
const response = {
data: {
status_code: status,
errors: [
{
error: valueGenerator.word(),
message,
},
],
},
} as AxiosResponse;
return new AxiosError(`Request failed with status code ${status}`, status.toString(), null, null, response);
};

it('calls notify client constructor', async () => {
await service.sendEmail(govUkNotifyKey, { sendToEmailAddress, templateId, personalisation });

expect(NotifyClient).toHaveBeenCalledTimes(1);
expect(NotifyClient).toHaveBeenCalledWith(govUkNotifyKey);
});

it('calls notify client with the specified request', async () => {
await service.sendEmail(govUkNotifyKey, { sendToEmailAddress, templateId, personalisation });

expect(sendEmailMethodMock).toHaveBeenCalledTimes(1);
});

it('returns successful response for the specified request', async () => {
const response = await service.sendEmail(govUkNotifyKey, { sendToEmailAddress, templateId, personalisation });

expect(response).toEqual({ status: 201, data: expectedResponse });
});

it('calls notify client with the specified field `reference` and get successful response', async () => {
const reference = valueGenerator.string({ length: 10 });
const response = await service.sendEmail(govUkNotifyKey, { sendToEmailAddress, templateId, personalisation, reference });

expect(sendEmailMethodMock).toHaveBeenCalledWith(templateId, sendToEmailAddress, { personalisation, reference });
expect(response).toEqual({ status: 201, data: expectedResponse });
});

it.each([
{
exceptionClass: BadRequestException,
exceptionName: 'Bad Request Exception',
error: 'Bad Request',
status: 400,
},
{
exceptionClass: UnauthorizedException,
exceptionName: 'Unauthorized Exception',
error: 'Unauthorized',
status: 401,
},
{
exceptionClass: ForbiddenException,
exceptionName: 'Forbidden Exception',
error: 'Forbidden',
status: 403,
},
])('handles notify client error response with status "$status"', async ({ exceptionClass, exceptionName, error, status }) => {
jest.mocked(sendEmailMethodMock).mockImplementation(() => Promise.reject(generateNotifyError(status, errorMessage)));

const resultPromise = service.sendEmail(govUkNotifyKey, { sendToEmailAddress, templateId, personalisation });

expect(sendEmailMethodMock).toHaveBeenCalledTimes(1);
await expect(resultPromise).rejects.toBeInstanceOf(exceptionClass);
await expect(resultPromise).rejects.toThrow(exceptionName);
await expect(resultPromise).rejects.toHaveProperty('status', status);
await expect(resultPromise).rejects.toHaveProperty('response', { message: [errorMessage], error, statusCode: status });
});

it('handles notify client error with unexpected status 900', async () => {
jest.mocked(sendEmailMethodMock).mockImplementation(() => Promise.reject(generateNotifyError(900, errorMessage)));

const resultPromise = service.sendEmail(govUkNotifyKey, { sendToEmailAddress, templateId, personalisation });

expect(sendEmailMethodMock).toHaveBeenCalledTimes(1);
await expect(resultPromise).rejects.toBeInstanceOf(Error);
await expect(resultPromise).rejects.toThrow(errorMessage);
await expect(resultPromise).rejects.toHaveProperty('message', errorMessage);
await expect(resultPromise).rejects.toHaveProperty('stack');
});

it('handles empty response from notify client', async () => {
jest.mocked(sendEmailMethodMock).mockImplementation(() => Promise.resolve(''));

const resultPromise = service.sendEmail(govUkNotifyKey, { sendToEmailAddress, templateId, personalisation });

expect(sendEmailMethodMock).toHaveBeenCalledTimes(1);
await expect(resultPromise).rejects.toBeInstanceOf(UnprocessableEntityException);
await expect(resultPromise).rejects.toThrow('No gov.uk response');
});
});
});
45 changes: 45 additions & 0 deletions src/helper-modules/govuk-notify/govuk-notify.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,45 @@
import { BadRequestException, ForbiddenException, Injectable, UnauthorizedException, UnprocessableEntityException } from '@nestjs/common';
import { NotifyClient } from 'notifications-node-client';

import { PostEmailRequestItemDto } from './dto/post-email-request.dto';
import { PostEmailResponseDto } from './dto/post-email-response.dto';

@Injectable()
export class GovukNotifyService {
constructor() {}

async sendEmail(govUkNotifyKey: string, email: PostEmailRequestItemDto): Promise<PostEmailResponseDto> {
// We create new client for each request because govUkNotifyKey (auth key) might be different.
const notifyClient = new NotifyClient(govUkNotifyKey);
const reference = email.reference || `${email.templateId}-${Date.now()}`;
const notifyResponse = await notifyClient
.sendEmail(email.templateId, email.sendToEmailAddress, {
personalisation: email.personalisation,
reference,
})
.then((response: any) => response)
.catch((err) => {
if (err?.response?.data?.errors[0].message) {
switch (err.response.data.status_code) {
case 400:
throw new BadRequestException([err.response.data.errors[0].message]);
case 401:
throw new UnauthorizedException([err.response.data.errors[0].message]);
case 403:
throw new ForbiddenException([err.response.data.errors[0].message]);
default:
throw new Error(err.response.data.errors[0].message);
}
} else {
throw new Error(JSON.stringify(err.response.data));
}
});

if (!notifyResponse) {
throw new UnprocessableEntityException('No gov.uk response');
}

const { status, data } = notifyResponse;
return { status, data };
}
}
13 changes: 13 additions & 0 deletions src/helper-modules/govuk-notify/known-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { NotFoundException } from '@nestjs/common';
import { AxiosError } from 'axios';

export type KnownErrors = KnownError[];

type KnownError = { caseInsensitiveSubstringToFind: string; throwError: (error: AxiosError) => never };

export const getAddressNotFoundKnownOrdnanceSurveyError = (): KnownError => ({
caseInsensitiveSubstringToFind: 'Address not found',
throwError: (error) => {
throw new NotFoundException('Address not found.', error);
},
});
Loading