Skip to content

Commit

Permalink
Merge remote-tracking branch 'origin/main' into feat/DTFS2-7049/new-e…
Browse files Browse the repository at this point in the history
…xternal-gov-notify-api
  • Loading branch information
avaitonis committed May 31, 2024
2 parents 86b5b4d + 7860528 commit 0e61f53
Show file tree
Hide file tree
Showing 43 changed files with 1,604 additions and 3 deletions.
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@
"npmrc",
"NVARCHAR",
"osgb",
"pscs",
"pino",
"pinojs",
"satify",
Expand Down
6 changes: 6 additions & 0 deletions .env.sample
Original file line number Diff line number Diff line change
Expand Up @@ -45,3 +45,9 @@ ORDNANCE_SURVEY_URL=https://api.os.uk
ORDNANCE_SURVEY_KEY=
ORDNANCE_SURVEY_MAX_REDIRECTS=
ORDNANCE_SURVEY_TIMEOUT= # in milliseconds

# COMPANIES HOUSE
COMPANIES_HOUSE_URL=
COMPANIES_HOUSE_KEY=
COMPANIES_HOUSE_MAX_REDIRECTS=
COMPANIES_HOUSE_TIMEOUT= # in milliseconds
4 changes: 4 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@ services:
ORDNANCE_SURVEY_KEY:
ORDNANCE_SURVEY_MAX_REDIRECTS:
ORDNANCE_SURVEY_TIMEOUT:
COMPANIES_HOUSE_URL:
COMPANIES_HOUSE_KEY:
COMPANIES_HOUSE_MAX_REDIRECTS:
COMPANIES_HOUSE_TIMEOUT:
API_KEY:
healthcheck:
test: ["CMD", "curl", "-f", "http://localhost:${PORT}"]
Expand Down
39 changes: 39 additions & 0 deletions src/config/companies-house.config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { withEnvironmentVariableParsingUnitTests } from '@ukef-test/common-tests/environment-variable-parsing-unit-tests';

import companiesHouseConfig, { CompaniesHouseConfig } from './companies-house.config';

describe('companiesHouseConfig', () => {
const configDirectlyFromEnvironmentVariables: { configPropertyName: keyof CompaniesHouseConfig; environmentVariableName: string }[] = [
{
configPropertyName: 'baseUrl',
environmentVariableName: 'COMPANIES_HOUSE_URL',
},
{
configPropertyName: 'key',
environmentVariableName: 'COMPANIES_HOUSE_KEY',
},
];

const configParsedAsIntFromEnvironmentVariablesWithDefault: {
configPropertyName: keyof CompaniesHouseConfig;
environmentVariableName: string;
defaultConfigValue: number;
}[] = [
{
configPropertyName: 'maxRedirects',
environmentVariableName: 'COMPANIES_HOUSE_MAX_REDIRECTS',
defaultConfigValue: 5,
},
{
configPropertyName: 'timeout',
environmentVariableName: 'COMPANIES_HOUSE_TIMEOUT',
defaultConfigValue: 30000,
},
];

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

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

export default registerAs(
COMPANIES_HOUSE.CONFIG.KEY,
(): CompaniesHouseConfig => ({
baseUrl: process.env.COMPANIES_HOUSE_URL,
key: process.env.COMPANIES_HOUSE_KEY,
maxRedirects: getIntConfig(process.env.COMPANIES_HOUSE_MAX_REDIRECTS, 5),
timeout: getIntConfig(process.env.COMPANIES_HOUSE_TIMEOUT, 30000),
}),
);
3 changes: 2 additions & 1 deletion src/config/index.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import AppConfig from './app.config';
import CompaniesHouseConfig from './companies-house.config';
import DatabaseConfig from './database.config';
import DocConfig from './doc.config';
import InformaticaConfig from './informatica.config';
import OrdnanceSurveyConfig from './ordnance-survey.config';

export default [AppConfig, DocConfig, DatabaseConfig, InformaticaConfig, OrdnanceSurveyConfig];
export default [AppConfig, CompaniesHouseConfig, DocConfig, DatabaseConfig, InformaticaConfig, OrdnanceSurveyConfig];
5 changes: 5 additions & 0 deletions src/constants/companies-house.constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const COMPANIES_HOUSE = {
CONFIG: {
KEY: 'companiesHouse',
},
};
10 changes: 10 additions & 0 deletions src/constants/companies.constant.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
export const COMPANIES = {
ENDPOINT_BASE_URL: '/api/v1/companies?registrationNumber=',
EXAMPLES: {
COMPANIES_HOUSE_REGISTRATION_NUMBER: '00000001',
},
REGEX: {
// This Companies House registration number regex was copied from the DTFS codebase.
COMPANIES_HOUSE_REGISTRATION_NUMBER: /^(([A-Z]{2}|[A-Z]\d|\d{2})(\d{5,6}|\d{4,5}[A-Z]))$/,
},
};
4 changes: 4 additions & 0 deletions src/constants/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,13 @@
* 7. Strings locations to redact
* 8. Module geospatial
* 9. GOV.UK notify email constants
* 10. Companies module
* 11. Companies House helper module
*/

export * from './auth.constant';
export * from './companies.constant';
export * from './companies-house.constant';
export * from './customers.constant';
export * from './database-name.constant';
export * from './date.constant';
Expand Down
27 changes: 27 additions & 0 deletions src/helper-modules/companies-house/companies-house.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 { CompaniesHouseConfig } from '@ukef/config/companies-house.config';
import { COMPANIES_HOUSE } from '@ukef/constants';
import { HttpModule } from '@ukef/modules/http/http.module';

import { CompaniesHouseService } from './companies-house.service';

@Module({
imports: [
HttpModule.registerAsync({
imports: [ConfigModule],
inject: [ConfigService],
useFactory: (configService: ConfigService) => {
const { baseUrl, maxRedirects, timeout } = configService.get<CompaniesHouseConfig>(COMPANIES_HOUSE.CONFIG.KEY);
return {
baseURL: baseUrl,
maxRedirects,
timeout,
};
},
}),
],
providers: [CompaniesHouseService],
exports: [CompaniesHouseService],
})
export class CompaniesHouseModule {}
169 changes: 169 additions & 0 deletions src/helper-modules/companies-house/companies-house.service.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
import { HttpService } from '@nestjs/axios';
import { ConfigService } from '@nestjs/config';
import { GetCompanyGenerator } from '@ukef-test/support/generator/get-company-generator';
import { RandomValueGenerator } from '@ukef-test/support/generator/random-value-generator';
import { AxiosError } from 'axios';
import { resetAllWhenMocks, when } from 'jest-when';
import { of, throwError } from 'rxjs';

import { CompaniesHouseService } from './companies-house.service';
import { CompaniesHouseException } from './exception/companies-house.exception';
import { CompaniesHouseInvalidAuthorizationException } from './exception/companies-house-invalid-authorization.exception';
import { CompaniesHouseMalformedAuthorizationHeaderException } from './exception/companies-house-malformed-authorization-header.exception';
import { CompaniesHouseNotFoundException } from './exception/companies-house-not-found.exception';

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

const valueGenerator = new RandomValueGenerator();

const testRegistrationNumber = '00000001';

const {
companiesHousePath,
getCompanyCompaniesHouseResponse,
getCompanyCompaniesHouseMalformedAuthorizationHeaderResponse,
getCompanyCompaniesHouseInvalidAuthorizationResponse,
getCompanyCompaniesHouseNotFoundResponse,
} = new GetCompanyGenerator(valueGenerator).generate({
numberToGenerate: 1,
registrationNumber: testRegistrationNumber,
});

const testKey = valueGenerator.string({ length: 40 });
const encodedTestKey = Buffer.from(testKey).toString('base64');

const expectedHttpServiceGetArguments: [string, object] = [
companiesHousePath,
{
headers: {
Authorization: `Basic ${encodedTestKey}`,
},
},
];

const expectedHttpServiceGetResponse = of({
data: getCompanyCompaniesHouseResponse,
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: testKey });
configService.get = configServiceGet;

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

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

describe('getCompanyByRegistrationNumber', () => {
it('calls the Companies House API with the correct arguments', async () => {
when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(expectedHttpServiceGetResponse);

await service.getCompanyByRegistrationNumber(testRegistrationNumber);

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

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

const response = await service.getCompanyByRegistrationNumber(testRegistrationNumber);

expect(response).toBe(getCompanyCompaniesHouseResponse);
});

it(`throws a CompaniesHouseMalformedAuthorizationHeaderException when the Companies House API returns a 400 response containing the error string 'Invalid Authorization header'`, async () => {
const axiosError = new AxiosError();
axiosError.response = {
data: getCompanyCompaniesHouseMalformedAuthorizationHeaderResponse,
status: 400,
statusText: 'Bad Request',
config: undefined,
headers: undefined,
};

when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(throwError(() => axiosError));

const getCompanyPromise = service.getCompanyByRegistrationNumber(testRegistrationNumber);

await expect(getCompanyPromise).rejects.toBeInstanceOf(CompaniesHouseMalformedAuthorizationHeaderException);
await expect(getCompanyPromise).rejects.toThrow(`Invalid 'Authorization' header. Check that your 'Authorization' header is well-formed.`);
await expect(getCompanyPromise).rejects.toHaveProperty('innerError', axiosError);
});

it(`throws a CompaniesHouseInvalidAuthorizationException when the Companies House API returns a 401 response containing the error string 'Invalid Authorization'`, async () => {
const axiosError = new AxiosError();
axiosError.response = {
data: getCompanyCompaniesHouseInvalidAuthorizationResponse,
status: 401,
statusText: 'Unauthorized',
config: undefined,
headers: undefined,
};

when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(throwError(() => axiosError));

const getCompanyPromise = service.getCompanyByRegistrationNumber(testRegistrationNumber);

await expect(getCompanyPromise).rejects.toBeInstanceOf(CompaniesHouseInvalidAuthorizationException);
await expect(getCompanyPromise).rejects.toThrow('Invalid authorization. Check your Companies House API key.');
await expect(getCompanyPromise).rejects.toHaveProperty('innerError', axiosError);
});

it(`throws a CompaniesHouseNotFoundException when the Companies House API returns a 404 response containing the error string 'company-profile-not-found'`, async () => {
const axiosError = new AxiosError();
axiosError.response = {
data: getCompanyCompaniesHouseNotFoundResponse,
status: 404,
statusText: 'Not Found',
config: undefined,
headers: undefined,
};

when(httpServiceGet)
.calledWith(...expectedHttpServiceGetArguments)
.mockReturnValueOnce(throwError(() => axiosError));

const getCompanyPromise = service.getCompanyByRegistrationNumber(testRegistrationNumber);

await expect(getCompanyPromise).rejects.toBeInstanceOf(CompaniesHouseNotFoundException);
await expect(getCompanyPromise).rejects.toThrow(`Company with registration number ${testRegistrationNumber} was not found.`);
await expect(getCompanyPromise).rejects.toHaveProperty('innerError', axiosError);
});

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

const getCompanyPromise = service.getCompanyByRegistrationNumber(testRegistrationNumber);

await expect(getCompanyPromise).rejects.toBeInstanceOf(CompaniesHouseException);
await expect(getCompanyPromise).rejects.toThrow('Failed to get response from Companies House API.');
await expect(getCompanyPromise).rejects.toHaveProperty('innerError', axiosError);
});
});
});
48 changes: 48 additions & 0 deletions src/helper-modules/companies-house/companies-house.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import { HttpService } from '@nestjs/axios';
import { Injectable } from '@nestjs/common';
import { ConfigService } from '@nestjs/config';
import { CompaniesHouseConfig } from '@ukef/config/companies-house.config';
import { COMPANIES_HOUSE } from '@ukef/constants';
import { HttpClient } from '@ukef/modules/http/http.client';

import { GetCompanyCompaniesHouseResponse } from './dto/get-company-companies-house-response.dto';
import {
getCompanyInvalidAuthorizationKnownCompaniesHouseError,
getCompanyMalformedAuthorizationHeaderKnownCompaniesHouseError,
getCompanyNotFoundKnownCompaniesHouseError,
} from './known-errors';
import { createWrapCompaniesHouseHttpGetErrorCallback } from './wrap-companies-house-http-error-callback';

@Injectable()
export class CompaniesHouseService {
private readonly httpClient: HttpClient;
private readonly key: string;

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

async getCompanyByRegistrationNumber(registrationNumber: string): Promise<GetCompanyCompaniesHouseResponse> {
const path = `/company/${registrationNumber}`;
const encodedKey = Buffer.from(this.key).toString('base64');

const { data } = await this.httpClient.get<GetCompanyCompaniesHouseResponse>({
path,
headers: {
Authorization: `Basic ${encodedKey}`,
},
onError: createWrapCompaniesHouseHttpGetErrorCallback({
messageForUnknownError: 'Failed to get response from Companies House API.',
knownErrors: [
getCompanyMalformedAuthorizationHeaderKnownCompaniesHouseError(),
getCompanyInvalidAuthorizationKnownCompaniesHouseError(),
getCompanyNotFoundKnownCompaniesHouseError(registrationNumber),
],
}),
});

return data;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
export type GetCompanyCompaniesHouseErrorResponse = {
error: string;
type: string;
};
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
import { GetCompanyCompaniesHouseErrorResponse } from './get-company-companies-house-error-response.dto';

export type GetCompanyCompaniesHouseMultipleErrorResponse = {
errors: GetCompanyCompaniesHouseErrorResponse[];
};
Loading

0 comments on commit 0e61f53

Please sign in to comment.