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

fix(company-house): company house API response #967

Merged
merged 10 commits into from
Jul 31, 2024
505 changes: 306 additions & 199 deletions package-lock.json

Large diffs are not rendered by default.

9 changes: 5 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,10 @@
"@types/lodash": "^4.17.7",
"@types/node": "^20.14.13",
"@types/supertest": "^6.0.2",
"@typescript-eslint/eslint-plugin": "^7.17.0",
"@typescript-eslint/parser": "^7.17.0",
"@typescript-eslint/eslint-plugin": "^7.18.0",
"@typescript-eslint/parser": "^7.18.0",
"chance": "^1.1.12",
"cspell": "^8.12.1",
"cspell": "^8.13.0",
"eslint": "8.57.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-airbnb-typescript": "^18.0.0",
Expand All @@ -92,13 +92,14 @@
"eslint-plugin-jest": "^28.6.0",
"eslint-plugin-jest-formatting": "^3.1.0",
"eslint-plugin-n": "^17.10.1",
"eslint-plugin-node": "^11.1.0",
"eslint-plugin-optimize-regex": "^1.2.1",
"eslint-plugin-prettier": "^5.2.1",
"eslint-plugin-security": "^3.0.1",
"eslint-plugin-simple-import-sort": "^12.1.1",
"eslint-plugin-switch-case": "^1.1.2",
"eslint-plugin-unused-imports": "^4.0.1",
"husky": "^9.1.3",
"husky": "^9.1.4",
"jest": "29.7.0",
"jest-when": "^3.6.0",
"lint-staged": "^15.2.7",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,7 @@ describe('CompaniesHouseService', () => {
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 () => {
it(`throws a CompaniesHouseNotFoundException when the Companies House API returns a 404 response status'`, async () => {
const axiosError = new AxiosError();
axiosError.response = {
data: getCompanyCompaniesHouseNotFoundResponse,
Expand Down
10 changes: 6 additions & 4 deletions src/helper-modules/companies-house/known-errors.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
import { HttpStatus } from '@nestjs/common';
import { messageCheck, statusCheck } from '@ukef/helpers/response-status.helper';
import { AxiosError } from 'axios';

import { CompaniesHouseInvalidAuthorizationException } from './exception/companies-house-invalid-authorization.exception';
Expand All @@ -6,10 +8,10 @@ import { CompaniesHouseNotFoundException } from './exception/companies-house-not

export type KnownErrors = KnownError[];

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

export const getCompanyMalformedAuthorizationHeaderKnownCompaniesHouseError = (): KnownError => ({
caseInsensitiveSubstringToFind: 'Invalid Authorization header',
checkHasError: (error) => statusCheck({ error, status: HttpStatus.BAD_REQUEST }) && messageCheck({ error, search: 'Invalid Authorization header' }),
throwError: (error) => {
throw new CompaniesHouseMalformedAuthorizationHeaderException(
`Invalid 'Authorization' header. Check that your 'Authorization' header is well-formed.`,
Expand All @@ -19,14 +21,14 @@ export const getCompanyMalformedAuthorizationHeaderKnownCompaniesHouseError = ()
});

export const getCompanyInvalidAuthorizationKnownCompaniesHouseError = (): KnownError => ({
caseInsensitiveSubstringToFind: 'Invalid Authorization',
checkHasError: (error) => statusCheck({ error, status: HttpStatus.UNAUTHORIZED }) && messageCheck({ error, search: 'Invalid Authorization' }),
throwError: (error) => {
throw new CompaniesHouseInvalidAuthorizationException('Invalid authorization. Check your Companies House API key.', error);
},
});

export const getCompanyNotFoundKnownCompaniesHouseError = (registrationNumber: string): KnownError => ({
caseInsensitiveSubstringToFind: 'company-profile-not-found',
checkHasError: (error) => statusCheck({ error, status: HttpStatus.NOT_FOUND }),
throwError: (error) => {
throw new CompaniesHouseNotFoundException(`Company with registration number ${registrationNumber} was not found.`, error);
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,25 +9,8 @@ type CompaniesHouseHttpErrorCallback = (error: Error) => ObservableInput<never>;
export const createWrapCompaniesHouseHttpGetErrorCallback =
({ messageForUnknownError, knownErrors }: { messageForUnknownError: string; knownErrors: KnownErrors }): CompaniesHouseHttpErrorCallback =>
(error: Error) => {
if (error instanceof AxiosError && error.response && typeof error.response.data === 'object') {
const errorResponseData = error.response.data;
let errorMessage: string;

if (typeof errorResponseData.error === 'string') {
errorMessage = errorResponseData.error;
} else if (errorResponseData.errors && errorResponseData.errors[0] && typeof errorResponseData.errors[0].error === 'string') {
errorMessage = errorResponseData.errors[0].error;
}

if (errorMessage) {
const errorMessageInLowerCase = errorMessage.toLowerCase();

knownErrors.forEach(({ caseInsensitiveSubstringToFind, throwError }) => {
if (errorMessageInLowerCase.includes(caseInsensitiveSubstringToFind.toLowerCase())) {
return throwError(error);
}
});
}
if (error instanceof AxiosError && error?.response) {
abhi-markan marked this conversation as resolved.
Show resolved Hide resolved
knownErrors.forEach(({ checkHasError, throwError }) => checkHasError(error) && throwError(error));
}

return throwError(() => new CompaniesHouseException(messageForUnknownError, error));
Expand Down
1 change: 1 addition & 0 deletions src/helpers/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './regex.helper';
export * from './response-status.helper';
export * from './transform-interceptor.helper';
174 changes: 174 additions & 0 deletions src/helpers/response-status.helper.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
import { HttpStatus } from '@nestjs/common';
import { isAxiosError } from 'axios';

import { messageCheck, statusCheck } from './response-status.helper';

jest.mock('axios', () => ({
isAxiosError: jest.fn(),
}));

describe('statusCheck', () => {
beforeEach(() => {
(isAxiosError as unknown as jest.Mock).mockReturnValue(true);
});

it('should return true when isAxiosError is true and status matches', () => {
const error = {
response: {
status: HttpStatus.BAD_REQUEST,
},
name: 'Bad request',
message: 'Bad request',
} as Error;
abhi-markan marked this conversation as resolved.
Show resolved Hide resolved

const result = statusCheck({ error, status: HttpStatus.BAD_REQUEST });

expect(result).toBe(true);
});

it('should return false when isAxiosError is true but status does not match', () => {
const error = {
response: {
status: HttpStatus.BAD_REQUEST,
},
name: 'Bad request',
message: 'Bad request',
} as Error;

const result = statusCheck({ error, status: HttpStatus.NOT_FOUND });

expect(result).toBe(false);
});

it('should return false when isAxiosError is false', () => {
const error = {
response: {
status: HttpStatus.BAD_REQUEST,
},
name: 'Bad request',
message: 'Bad request',
} as Error;

(isAxiosError as unknown as jest.Mock).mockReturnValue(false);

const result = statusCheck({ error, status: HttpStatus.BAD_REQUEST });

expect(result).toBe(false);
});

it('should return false when error does not have a response', () => {
const error = {} as Error;

const result = statusCheck({ error, status: HttpStatus.BAD_REQUEST });

expect(result).toBe(false);
});
});

describe('messageCheck', () => {
beforeEach(() => {
(isAxiosError as unknown as jest.Mock).mockReturnValue(true);
});

it('should return true when isAxiosError is true and message contains search string', () => {
const error = {
response: {
data: {
error: 'Invalid Authorization',
type: 'ch:service',
},
},
status: 401,
statusText: 'Unauthorized',
name: 'Error',
message: 'Error',
} as Error;

const result = messageCheck({ error, search: 'Invalid Authorization' });

expect(result).toBe(true);
});

it('should return true when isAxiosError is true and message contains similar search string', () => {
const error = {
response: {
data: {
error: 'Invalid Authorization header',
type: 'ch:service',
},
},
status: 400,
statusText: 'Bad Request',
name: 'Error',
message: 'Error',
} as Error;

const result = messageCheck({ error, search: 'Invalid Authorization header' });

expect(result).toBe(true);
});

it('should return false when isAxiosError is true but message does not contain search string', () => {
const error = {
response: {
data: {
error: 'Invalid Authorization',
type: 'ch:service',
},
},
status: 401,
statusText: 'Unauthorized',
name: 'Error',
message: 'Error',
} as Error;

const result = messageCheck({ error, search: 'Not found' });

expect(result).toBe(false);
});

it('should return false when isAxiosError is false', () => {
const error = {
response: {
data: {
error: 'Invalid Authorization',
type: 'ch:service',
},
},
status: 401,
statusText: 'Unauthorized',
name: 'Error',
message: 'Error',
} as Error;

(isAxiosError as unknown as jest.Mock).mockReturnValue(false);

const result = messageCheck({ error, search: 'Invalid Authorization header' });

expect(result).toBe(false);
});

it('should return false when error does not have a response', () => {
const error = {} as Error;

const result = messageCheck({ error, search: 'Invalid Authorization header' });

expect(result).toBe(false);
});

it('should return false when error response does not contain data', () => {
const error = {
response: {
data: undefined,
},
status: 404,
statusText: 'Not Found',
name: 'Error',
message: 'Invalid Authorization header',
} as Error;

const result = messageCheck({ error, search: 'Invalid Authorization header' });

expect(result).toBe(false);
});
});
34 changes: 34 additions & 0 deletions src/helpers/response-status.helper.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { HttpStatus } from '@nestjs/common';
import { isAxiosError } from 'axios';

/**
* Checks if the error object is an AxiosError and has a response status matching the specified status.
* @param options - An object containing the error and status.
* @param options.error - The error object to check.
* @param options.status - The status to compare against.
* @returns - Returns true if the error is an AxiosError and has a matching response status, otherwise false.
*/
export const statusCheck = ({ error, status }: { error: Error; status: HttpStatus }): boolean => isAxiosError(error) && error.response?.status === status;
abhi-markan marked this conversation as resolved.
Show resolved Hide resolved

/**
* Validates the error response against the specified search string.
* @param options - An object containing the error and search string.
* @param options.error - The error object to validate.
* @param options.search - The search string to compare against the error message.
* @returns - Returns true if the error response contains the search string, otherwise false.
*/
export const messageCheck = ({ error, search }: { error: Error; search: string }): boolean => {
abhi-markan marked this conversation as resolved.
Show resolved Hide resolved
if (!isAxiosError(error) || !error?.response?.data) {
abhi-markan marked this conversation as resolved.
Show resolved Hide resolved
return false;
}

let message: string;

abhi-markan marked this conversation as resolved.
Show resolved Hide resolved
if (typeof error?.response?.data?.error === 'string') {
message = error.response.data.error;
} else if (error?.response?.data?.errors?.[0] && typeof error?.response?.data?.errors?.[0]?.error === 'string') {
message = error.response.data.errors[0].error;
}

return message.toLowerCase().includes(search.toLowerCase()) ?? false;
};
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,7 @@ describe('GET /companies?registrationNumber=', () => {
},
);

it(`returns a 404 response if the Companies House API returns a 404 response containing the error string 'company-profile-not-found'`, async () => {
it(`returns a 404 response if the Companies House API returns a 404 response status code`, async () => {
requestToGetCompanyByRegistrationNumber(companiesHousePath).reply(404, getCompanyCompaniesHouseNotFoundResponse);

const { status, body } = await api.get(mdmPath);
Expand Down
43 changes: 40 additions & 3 deletions test/docs/__snapshots__/get-docs-yaml.api-test.ts.snap
Original file line number Diff line number Diff line change
Expand Up @@ -158,9 +158,7 @@ paths:
content:
application/json:
schema:
type: array
items:
$ref: '#/components/schemas/PostEmailsRequestDto'
$ref: '#/components/schemas/PostEmailsRequestDto'
responses:
'201':
description: Returns information about email transaction.
Expand Down Expand Up @@ -555,6 +553,42 @@ paths:
description: No addresses found
tags:
- geospatial
/api/v1/companies:
get:
operationId: CompaniesController_getCompanyByRegistrationNumber
summary: Get company by Companies House registration number.
parameters:
- name: registrationNumber
required: true
in: query
description: The Companies House registration number of the company to find.
example: '00000001'
schema:
minLength: 8
maxLength: 8
pattern: ^(([A-Z]{2}|[A-Z]\\d|\\d{2})(\\d{6}|\\d{5}[A-Z]))$
type: string
responses:
'200':
description: >-
Returns the company matching the Companies House registration
number.
content:
application/json:
schema:
$ref: '#/components/schemas/GetCompanyResponse'
'400':
description: Invalid Companies House registration number.
'404':
description: Company not found.
'422':
description: >-
Company is an overseas company. UKEF can only process applications
from companies based in the UK.
'500':
description: Internal server error.
tags:
- companies
info:
title: MDM API Specification
description: MDM API documentation
Expand Down Expand Up @@ -1343,6 +1377,9 @@ components:
- locality
- postalCode
- country
GetCompanyResponse:
type: object
properties: {}
security:
- ApiKeyHeader: []
"
Expand Down
Loading
Loading