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(FN-3163): MDM endpoints for automatically creating new DTFS customers in Salesforce #1057

Draft
wants to merge 54 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from 50 commits
Commits
Show all changes
54 commits
Select commit Hold shift + click to select a range
36c5a61
feat: dummy customer creation endpoint
Sep 13, 2024
7e5da7f
feat: wip add salesforce service and module bypassing self-signed iss…
Sep 13, 2024
b5bc3ec
feat: dummy post request
Sep 16, 2024
6799e95
feat: wip post request
Sep 16, 2024
0a3b09d
feat: wip post request
Sep 16, 2024
9b9575f
feat: minimal sf post
Sep 17, 2024
6efbaec
refactor: cleanup
Sep 17, 2024
49741a9
feat: use custom create customer reopnse dto
Sep 17, 2024
83fd735
feat: use isarray in dto
Sep 17, 2024
78461a7
feat: redact sensitive salesforce api data
Sep 17, 2024
a5d193a
feat: add custom sf error objects
Sep 17, 2024
fcadab7
test: update old test suites
Sep 19, 2024
942e214
refactor: fix typo
Sep 23, 2024
c657b56
feat: add unrestricted mvp fields
Sep 24, 2024
585d512
feat: prepare for DnB integration
Sep 24, 2024
358ca65
feat: continue mocking full customer creation
Oct 4, 2024
57e8351
feat: add direct salesforce GET as agreed with data team
Oct 7, 2024
56dfe6e
feat: return 404 when no customer found
Oct 8, 2024
12b9c7e
feat: update customer service tests
Oct 8, 2024
87632c7
feat: update customer controller tests
Oct 8, 2024
b1da084
feat: add create test to customer controller tests
Oct 8, 2024
e52def8
feat: add salesforce exception test
Oct 8, 2024
f1c735e
feat: update salesforce response descriptions
Oct 9, 2024
bd44e69
feat: add salesforce service tests
Oct 9, 2024
c2c1a15
feat: typos
Oct 9, 2024
6bdb9ac
feat: add salesforce create tests
Oct 10, 2024
f9ec313
feat: use minimum fieldset for account creation
Oct 14, 2024
15833a9
feat: update snapshot
Oct 14, 2024
fef5bc5
feat: update config behaviour and tests
Oct 14, 2024
1ecaae6
feat: post customer name to SF with D&B number
Oct 16, 2024
340255d
feta: update tests
Oct 16, 2024
18d0b24
feat: add companies house number to POST
Oct 22, 2024
7b06c67
feat: update direct get dtos
Oct 22, 2024
1f0cf13
feat: update tests
Oct 22, 2024
a34fe69
feat: mock salesforce response correctly
Oct 22, 2024
b64befd
feat: update snapshot
Oct 22, 2024
16aa781
feat: return created customer after creation and update error handling
Oct 24, 2024
0429b1b
feat: generate PartyURN before POSTing
Oct 24, 2024
df59b40
comment: add comment
Oct 24, 2024
4af9871
feat: update error handling and add sql validation
Oct 25, 2024
78d83e4
feat: continue on noncritical errors and return efficiently
Oct 28, 2024
7218992
feat: update tests and error handling
Oct 28, 2024
0af54b8
feat: update api tests
Oct 28, 2024
10eee2f
feat: add minimal DnB GET DUNS endpoint
Oct 29, 2024
0e31b33
feat: update tests
Oct 29, 2024
747462e
feat: update customers service tests
Oct 29, 2024
08c207e
feat: update error throwing mock behaviour
Oct 30, 2024
8522682
feat: remove redundant companies endpoint built for testing
Oct 30, 2024
b53c768
feat: add d&b service and exception tests
Oct 30, 2024
b413291
feat: rename endpoint direct -> sf
Nov 7, 2024
ba2f76e
feat: comment out rejectUnauthorized for prod
Nov 11, 2024
4179e4d
docs: update readme
Nov 11, 2024
ae8d988
docs: update comments
Nov 11, 2024
338f7cd
docs: update comments
Nov 11, 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
12 changes: 12 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,18 @@ services:
COMPANIES_HOUSE_KEY:
COMPANIES_HOUSE_MAX_REDIRECTS:
COMPANIES_HOUSE_TIMEOUT:
DUN_AND_BRADSTREET_URL:
DUN_AND_BRADSTREET_KEY:
DUN_AND_BRADSTREET_MAX_REDIRECTS:
DUN_AND_BRADSTREET_TIMEOUT:
SF_CLIENT_ID:
SF_CLIENT_SECRET:
SF_USERNAME:
SF_PASSWORD:
SF_INSTANCE_URL:
SF_ACCESS_URL:
SF_MAX_REDIRECTS:
SF_TIMEOUT:
API_KEY:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${PORT}"]
Expand Down
20 changes: 20 additions & 0 deletions src/config/dun-and-bradstreet.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { registerAs } from '@nestjs/config';
import { DUN_AND_BRADSTREET } from '@ukef/constants';
import { getIntConfig } from '@ukef/helpers/get-int-config';

export interface DunAndBradstreetConfig {
baseUrl: string;
key: string;
maxRedirects: number;
timeout: number;
}

export default registerAs(
DUN_AND_BRADSTREET.CONFIG.KEY,
(): DunAndBradstreetConfig => ({
baseUrl: process.env.DUN_AND_BRADSTREET_URL,
key: process.env.DUN_AND_BRADSTREET_KEY,
maxRedirects: getIntConfig(process.env.DUN_AND_BRADSTREET_MAX_REDIRECTS, 5),
timeout: getIntConfig(process.env.DUN_AND_BRADSTREET_TIMEOUT, 30000),
}),
);
4 changes: 3 additions & 1 deletion src/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import AppConfig from './app.config';
import CompaniesHouseConfig from './companies-house.config';
import DatabaseConfig from './database.config';
import DocConfig from './doc.config';
import DunAndBradstreetConfig from './dun-and-bradstreet.config';
import InformaticaConfig from './informatica.config';
import OrdnanceSurveyConfig from './ordnance-survey.config';
import SalesforceConfig from './salesforce.config';

export default [AppConfig, CompaniesHouseConfig, DocConfig, DatabaseConfig, InformaticaConfig, OrdnanceSurveyConfig];
export default [AppConfig, CompaniesHouseConfig, DocConfig, DatabaseConfig, DunAndBradstreetConfig, InformaticaConfig, OrdnanceSurveyConfig, SalesforceConfig];
56 changes: 56 additions & 0 deletions src/config/salesforce.config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
import { withEnvironmentVariableParsingUnitTests } from '@ukef-test/common-tests/environment-variable-parsing-unit-tests';

import salesforceConfig, { SalesforceConfig } from './salesforce.config';

describe('salesforceConfig', () => {
const configDirectlyFromEnvironmentVariables: { configPropertyName: keyof SalesforceConfig; environmentVariableName: string }[] = [
{
configPropertyName: 'baseUrl',
environmentVariableName: 'SF_INSTANCE_URL',
},
{
configPropertyName: 'clientId',
environmentVariableName: 'SF_CLIENT_ID',
},

{
configPropertyName: 'clientSecret',
environmentVariableName: 'SF_CLIENT_SECRET',
},
{
configPropertyName: 'username',
environmentVariableName: 'SF_USERNAME',
},
{
configPropertyName: 'password',
environmentVariableName: 'SF_PASSWORD',
},
{
configPropertyName: 'accessUrl',
environmentVariableName: 'SF_ACCESS_URL',
},
];

const configParsedAsIntFromEnvironmentVariablesWithDefault: {
configPropertyName: keyof SalesforceConfig;
environmentVariableName: string;
defaultConfigValue: number;
}[] = [
{
configPropertyName: 'maxRedirects',
environmentVariableName: 'SF_MAX_REDIRECTS',
defaultConfigValue: 5,
},
{
configPropertyName: 'timeout',
environmentVariableName: 'SF_TIMEOUT',
defaultConfigValue: 30000, // in milliseconds
},
];

withEnvironmentVariableParsingUnitTests({
configDirectlyFromEnvironmentVariables,
configParsedAsIntFromEnvironmentVariablesWithDefault,
getConfig: () => salesforceConfig(),
});
});
29 changes: 29 additions & 0 deletions src/config/salesforce.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { registerAs } from '@nestjs/config';
import { getIntConfig } from '@ukef/helpers/get-int-config';

export const KEY = 'salesforce';

export interface SalesforceConfig {
baseUrl: string;
clientId: string;
clientSecret: string;
username: string;
password: string;
accessUrl: string;
maxRedirects: number;
timeout: number;
}

export default registerAs(
KEY,
(): SalesforceConfig => ({
baseUrl: process.env.SF_INSTANCE_URL,
clientId: process.env.SF_CLIENT_ID,
clientSecret: process.env.SF_CLIENT_SECRET,
username: process.env.SF_USERNAME,
password: process.env.SF_PASSWORD,
accessUrl: process.env.SF_ACCESS_URL,
maxRedirects: getIntConfig(process.env.SF_MAX_REDIRECTS, 5),
timeout: getIntConfig(process.env.SF_TIMEOUT, 30000), // in milliseconds
}),
);
5 changes: 5 additions & 0 deletions src/constants/dun-and-bradstreet.constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const DUN_AND_BRADSTREET = {
CONFIG: {
KEY: 'dunAndBradstreet',
},
};
1 change: 1 addition & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export * from './companies-house.constant';
export * from './customers.constant';
export * from './database-name.constant';
export * from './date.constant';
export * from './dun-and-bradstreet.constant';
export * from './enums';
export * from './geospatial.constant';
export * from './govuk-notify.constant';
Expand Down
27 changes: 27 additions & 0 deletions src/helper-modules/dun-and-bradstreet/dun-and-bradstreet.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Module } from '@nestjs/common';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { DunAndBradstreetConfig } from '@ukef/config/dun-and-bradstreet.config';
import { DUN_AND_BRADSTREET } from '@ukef/constants';
import { HttpModule } from '@ukef/modules/http/http.module';

import { DunAndBradstreetService } from './dun-and-bradstreet.service';

@Module({
imports: [
HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
const { baseUrl, maxRedirects, timeout } = configService.get<DunAndBradstreetConfig>(DUN_AND_BRADSTREET.CONFIG.KEY);
return {
baseURL: baseUrl,
maxRedirects,
timeout,
};
},
}),
],
providers: [DunAndBradstreetService],
exports: [DunAndBradstreetService],
})
export class DunAndBradstreetModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,112 @@
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { resetAllWhenMocks, when } from 'jest-when';
import { of, throwError } from 'rxjs';

import { DunAndBradstreetService } from './dun-and-bradstreet.service';
import { DunAndBradstreetException } from './exception/dun-and-bradstreet.exception';
import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator';
import { AxiosError } from 'axios';

describe('CompaniesHouseService', () => {
let httpServiceGet: jest.Mock;
let configServiceGet: jest.Mock;
let service: DunAndBradstreetService;

const valueGenerator = new RandomValueGenerator();

const testRegistrationNumber = '0' + valueGenerator.stringOfNumericCharacters({ length: 7 });
const expectedAccessToken = 'TEST_ACCESS_TOKEN';
const getAccessTokenMethodMock = jest
.spyOn(DunAndBradstreetService.prototype as any, 'getAccessToken')
.mockImplementation(() => Promise.resolve(expectedAccessToken))

const dunAndBradstreetpath = `/v1/match/cleanseMatch?countryISOAlpha2Code=GB&registrationNumber=${testRegistrationNumber}`;
const expectedDunsNumber = "123456789"
const getDunsNumberDunAndBradstreetResponse = {
"matchCandidates": [
{
"organization": {
"duns": expectedDunsNumber
}
}
]
}


const expectedHttpServiceGetArguments: [string, object] = [
dunAndBradstreetpath,
{
headers: {
Authorization: `Bearer ${expectedAccessToken}`,
},
},
];

const expectedHttpServiceGetResponse = of({
data: getDunsNumberDunAndBradstreetResponse,
status: 200,
statusText: 'OK',
config: undefined,
headers: undefined,
});

beforeAll(() => {
const httpService = new HttpService();
httpServiceGet = jest.fn();
httpService.get = httpServiceGet;

const configService = new ConfigService();
configServiceGet = jest.fn().mockReturnValue({ key: "TEST API_KEY" });
configService.get = configServiceGet;

service = new DunAndBradstreetService(httpService, configService);
});

beforeEach(() => {
resetAllWhenMocks();
});

afterEach(() => {
jest.clearAllMocks();
});

describe('getDunAndBradstreetNumberByRegistrationNumber', () => {
it('calls the Dun and Bradstreet API with the correct arguments', async () => {
when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(expectedHttpServiceGetResponse);

await service.getDunAndBradstreetNumberByRegistrationNumber(testRegistrationNumber);

expect(getAccessTokenMethodMock).toHaveBeenCalledTimes(1);
expect(httpServiceGet).toHaveBeenCalledTimes(1);
expect(httpServiceGet).toHaveBeenCalledWith(...expectedHttpServiceGetArguments);
});

it('returns the results when the Dun and Bradstreet API returns a 200 response with results', async () => {
when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(expectedHttpServiceGetResponse);

const response = await service.getDunAndBradstreetNumberByRegistrationNumber(testRegistrationNumber);

expect(getAccessTokenMethodMock).toHaveBeenCalledTimes(1);
expect(response).toBe(expectedDunsNumber);
});

it('throws a DunAndBradstreetException if the Dun and Bradstreet API returns an unknown error response', async () => {
const axiosError = new AxiosError();
when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(throwError(() => axiosError));

const getDunsNumberPromise = service.getDunAndBradstreetNumberByRegistrationNumber(testRegistrationNumber);

expect(getAccessTokenMethodMock).toHaveBeenCalledTimes(1);
await expect(getDunsNumberPromise).rejects.toBeInstanceOf(DunAndBradstreetException);
await expect(getDunsNumberPromise).rejects.toThrow('Failed to get response from Dun and Bradstreet API');
await expect(getDunsNumberPromise).rejects.toHaveProperty('innerError', axiosError);
});
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { DUN_AND_BRADSTREET } from '@ukef/constants';
import { HttpClient } from '@ukef/modules/http/http.client';

import { DunAndBradstreetConfig } from '@ukef/config/dun-and-bradstreet.config';
import { createWrapDunAndBradstreetHttpGetErrorCallback } from './wrap-dun-and-bradstreet-http-error-callback';

@Injectable()
export class DunAndBradstreetService {
private readonly httpClient: HttpClient;
private readonly encoded_key: string;


constructor(httpService: HttpService, configService: ConfigService) {
this.httpClient = new HttpClient(httpService);
const { key } = configService.get<DunAndBradstreetConfig>(DUN_AND_BRADSTREET.CONFIG.KEY);
this.encoded_key = key;
}

async getDunAndBradstreetNumberByRegistrationNumber(registrationNumber: string): Promise<string> {
const path = `/v1/match/cleanseMatch?countryISOAlpha2Code=GB&registrationNumber=${registrationNumber}`;
const access_token = await this.getAccessToken();

const { data } = await this.httpClient.get<any>({
path,
headers: {
'Authorization': 'Bearer ' + access_token,
},
onError: createWrapDunAndBradstreetHttpGetErrorCallback({
messageForUnknownError: 'Failed to get response from Dun and Bradstreet API',
knownErrors: [],
}),
});
return data?.matchCandidates[0]?.organization?.duns;
}

private async getAccessToken(): Promise<string> {
const path = '/v3/token'
const response = await this.httpClient.post<any, any>({
path,
body: {
'grant_type': 'client_credentials',
},
headers: {
'Authorization': 'Basic ' + this.encoded_key,
'Content-Type': 'application/x-www-form-urlencoded',
},
onError: createWrapDunAndBradstreetHttpGetErrorCallback({
messageForUnknownError: 'Failed to get access token',
knownErrors: [],
}),
})
return response.data.access_token
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator';

import { DunAndBradstreetException } from './dun-and-bradstreet.exception';

describe('CompaniesHouseException', () => {
const valueGenerator = new RandomValueGenerator();
const message = valueGenerator.string();

it('exposes the message it was created with', () => {
const exception = new DunAndBradstreetException(message);

expect(exception.message).toBe(message);
});

it('exposes the name of the exception', () => {
const exception = new DunAndBradstreetException(message);

expect(exception.name).toBe('DunAndBradstreetException');
});

it('exposes the inner error it was created with', () => {
const innerError = new Error();

const exception = new DunAndBradstreetException(message, innerError);

expect(exception.innerError).toBe(innerError);
});
});
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
export class DunAndBradstreetException extends Error {
constructor(
message: string,
public readonly innerError?: Error,
) {
super(message);
this.name = this.constructor.name;
}
}
5 changes: 5 additions & 0 deletions src/helper-modules/dun-and-bradstreet/known-errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { AxiosError } from 'axios';

export type KnownErrors = KnownError[];

type KnownError = { checkHasError: (error: Error) => boolean; throwError: (error: AxiosError) => never };
Loading
Loading