diff --git a/sources/packages/backend/libs/integrations/src/cas/_tests_/unit/cas-test.utils.ts b/sources/packages/backend/libs/integrations/src/cas/_tests_/unit/cas-test.utils.ts new file mode 100644 index 0000000000..7e8125c973 --- /dev/null +++ b/sources/packages/backend/libs/integrations/src/cas/_tests_/unit/cas-test.utils.ts @@ -0,0 +1,53 @@ +import { HttpService } from "@nestjs/axios"; +import { CASService } from "@sims/integrations/cas"; +import { ConfigService } from "@sims/utilities/config"; +import { Mocked } from "@suites/doubles.jest"; +import { TestBed } from "@suites/unit"; + +/** + * Default access token used for authentication. + */ +const ACCESS_TOKEN = "access_token"; + +/** + * Default axios headers for authentication. + */ +export const DEFAULT_CAS_AXIOS_AUTH_HEADER = { + headers: { Authorization: `Bearer ${ACCESS_TOKEN}` }, +}; + +/** + * Mock the first post call for authentication. + * @param httpServiceMock mocked for HTTP service. + * @returns mocked axios response. + */ +export function mockAuthenticationResponseOnce( + httpServiceMock: Mocked, +): jest.Mock { + const httpMethodMock = httpServiceMock.axiosRef.post as jest.Mock; + return httpMethodMock.mockResolvedValueOnce({ + data: { + access_token: ACCESS_TOKEN, + token_type: "bearer", + expires_in: 3600, + }, + }); +} + +/** + * Initializes the service under test. + * @returns array of mocked services. + */ +export async function initializeService(): Promise< + [ + casService: CASService, + httpService: Mocked, + configService: Mocked, + ] +> { + const { unit, unitRef } = await TestBed.solitary(CASService).compile(); + const configService = unitRef.get(ConfigService); + const httpService = unitRef.get(HttpService); + configService.casIntegration.baseUrl = "cas-url"; + return [unit, httpService, configService]; +} diff --git a/sources/packages/backend/libs/integrations/src/cas/_tests_/unit/cas.service.createSupplierAndSite.spec.ts b/sources/packages/backend/libs/integrations/src/cas/_tests_/unit/cas.service.createSupplierAndSite.spec.ts new file mode 100644 index 0000000000..25004e6862 --- /dev/null +++ b/sources/packages/backend/libs/integrations/src/cas/_tests_/unit/cas.service.createSupplierAndSite.spec.ts @@ -0,0 +1,70 @@ +import { CASService, CreateSupplierAndSiteData } from "@sims/integrations/cas"; +import { Mocked } from "@suites/unit"; +import { HttpService } from "@nestjs/axios"; +import { + DEFAULT_CAS_AXIOS_AUTH_HEADER, + initializeService, + mockAuthenticationResponseOnce, +} from "./cas-test.utils"; + +describe("CASService-createSupplierAndSite", () => { + let casService: CASService; + let httpService: Mocked; + + beforeAll(async () => { + [casService, httpService] = await initializeService(); + }); + + beforeEach(() => { + jest.resetAllMocks(); + }); + + it("Should invoke CAS API with formatted payload when all data was provided as expected.", async () => { + // Arrange + mockAuthenticationResponseOnce(httpService).mockResolvedValue({ + data: { + SUPPLIER_NUMBER: "123456", + SUPPLIER_SITE_CODE: "001", + }, + }); + + const supplierData: CreateSupplierAndSiteData = { + firstName: + "First With Special Characters áÉíÓú and Very Extensive Number of Characters", + lastName: "Some Last-Name With Length Over the Maximum", + sin: "999999999", + emailAddress: "test@test.com", + supplierSite: { + addressLine1: "Street-Special Characters-ãñè-Maximum", + city: "City Name Over Maximum Length", + provinceCode: "BC", + postalCode: "h1h h2h", + }, + }; + + // Act + await casService.createSupplierAndSite(supplierData); + + // Assert + expect(httpService.axiosRef.post).toHaveBeenCalledWith( + "cas-url/cfs/supplier/", + { + SupplierName: + "SOME LAST-NAME WITH LENGTH OVER THE MAXIMUM, FIRST WITH SPECIAL CHARACTERS AEIOU", + SubCategory: "Individual", + Sin: "999999999", + SupplierAddress: [ + { + AddressLine1: "STREET-SPECIAL CHARACTERS-ANE-MAXIM", + City: "City Name Over Maximum Le", + Province: "BC", + Country: "CA", + PostalCode: "H1HH2H", + EmailAddress: supplierData.emailAddress, + }, + ], + }, + DEFAULT_CAS_AXIOS_AUTH_HEADER, + ); + }); +}); diff --git a/sources/packages/backend/libs/integrations/src/cas/_tests_/unit/cas.service.getSupplierInfoFromCAS.spec.ts b/sources/packages/backend/libs/integrations/src/cas/_tests_/unit/cas.service.getSupplierInfoFromCAS.spec.ts index 821d9b6ede..7113149010 100644 --- a/sources/packages/backend/libs/integrations/src/cas/_tests_/unit/cas.service.getSupplierInfoFromCAS.spec.ts +++ b/sources/packages/backend/libs/integrations/src/cas/_tests_/unit/cas.service.getSupplierInfoFromCAS.spec.ts @@ -1,21 +1,18 @@ -import { CASService } from "@sims/integrations/cas/cas.service"; -import { TestBed, Mocked } from "@suites/unit"; +import { CASService } from "@sims/integrations/cas"; +import { Mocked } from "@suites/unit"; import { HttpService } from "@nestjs/axios"; -import { ConfigService } from "@sims/utilities/config"; - -const ACCESS_TOKEN = "access_token"; +import { + DEFAULT_CAS_AXIOS_AUTH_HEADER, + initializeService, + mockAuthenticationResponseOnce, +} from "./cas-test.utils"; describe("CASService-getSupplierInfoFromCAS", () => { let casService: CASService; let httpService: Mocked; - let configService: Mocked; beforeAll(async () => { - const { unit, unitRef } = await TestBed.solitary(CASService).compile(); - casService = unit; - configService = unitRef.get(ConfigService); - httpService = unitRef.get(HttpService); - configService.casIntegration.baseUrl = "cas-url"; + [casService, httpService] = await initializeService(); }); beforeEach(() => { @@ -24,7 +21,7 @@ describe("CASService-getSupplierInfoFromCAS", () => { it("Should invoke CAS API with last name upper case and without special characters when last name has special characters and is not entirely upper case.", async () => { // Arrange - mockAuthResponse(); + mockAuthenticationResponseOnce(httpService); // Act await casService.getSupplierInfoFromCAS( @@ -35,20 +32,7 @@ describe("CASService-getSupplierInfoFromCAS", () => { // Assert expect(httpService.axiosRef.get).toHaveBeenCalledWith( "cas-url/cfs/supplier/LAST NAME WITH SPECIAL CHARACTERS: AEIOU-AEIOU/lastname/dummy_sin_value/sin", - { headers: { Authorization: `Bearer ${ACCESS_TOKEN}` } }, + DEFAULT_CAS_AXIOS_AUTH_HEADER, ); }); - - /** - * Mock the first post call for authentication. - */ - function mockAuthResponse(): void { - httpService.axiosRef.post = jest.fn().mockResolvedValueOnce({ - data: { - access_token: ACCESS_TOKEN, - token_type: "bearer", - expires_in: 3600, - }, - }); - } }); diff --git a/sources/packages/backend/libs/integrations/src/cas/cas-formatters.ts b/sources/packages/backend/libs/integrations/src/cas/cas-formatters.ts index 7db06a4ed2..3d72383558 100644 --- a/sources/packages/backend/libs/integrations/src/cas/cas-formatters.ts +++ b/sources/packages/backend/libs/integrations/src/cas/cas-formatters.ts @@ -13,10 +13,9 @@ const CAS_CITY_MAX_LENGTH = 25; * @returns formatted full name. */ export function formatUserName(firstName: string, lastName: string): string { - const formattedName = `${lastName}, ${firstName}`.substring( - 0, - CAS_SUPPLIER_NAME_MAX_LENGTH, - ); + const formattedName = `${lastName}, ${firstName}` + .substring(0, CAS_SUPPLIER_NAME_MAX_LENGTH) + .trim(); return convertToASCIIString(formattedName).toUpperCase(); } @@ -28,7 +27,7 @@ export function formatUserName(firstName: string, lastName: string): string { */ export function formatAddress(address: string): string { return convertToASCIIString( - address.substring(0, CAS_ADDRESS_MAX_LENGTH), + address.substring(0, CAS_ADDRESS_MAX_LENGTH).trim(), ).toUpperCase(); } @@ -38,7 +37,7 @@ export function formatAddress(address: string): string { * @returns formatted city. */ export function formatCity(city: string): string { - return city.substring(0, CAS_CITY_MAX_LENGTH); + return city.substring(0, CAS_CITY_MAX_LENGTH).trim(); } /** diff --git a/sources/packages/backend/libs/integrations/src/cas/cas.service.ts b/sources/packages/backend/libs/integrations/src/cas/cas.service.ts index 7ca098a456..ff8709ed68 100644 --- a/sources/packages/backend/libs/integrations/src/cas/cas.service.ts +++ b/sources/packages/backend/libs/integrations/src/cas/cas.service.ts @@ -1,4 +1,4 @@ -import { Injectable, LoggerService } from "@nestjs/common"; +import { HttpStatus, Injectable, LoggerService } from "@nestjs/common"; import { CASAuthDetails, CASSupplierResponse, @@ -6,7 +6,7 @@ import { CreateSupplierAndSiteResponse, CreateSupplierAndSiteSubmittedData, } from "./models/cas-service.model"; -import { AxiosRequestConfig } from "axios"; +import { AxiosError, AxiosRequestConfig } from "axios"; import { HttpService } from "@nestjs/axios"; import { CASIntegrationConfig, ConfigService } from "@sims/utilities/config"; import { stringify } from "querystring"; @@ -15,7 +15,7 @@ import { convertToASCIIString, parseJSONError, } from "@sims/utilities"; -import { CAS_AUTH_ERROR } from "@sims/integrations/constants"; +import { CAS_AUTH_ERROR, CAS_BAD_REQUEST } from "@sims/integrations/constants"; import { InjectLogger } from "@sims/utilities/logger"; import { CASCachedAuthDetails, @@ -25,6 +25,11 @@ import { formatUserName, } from "."; +/** + * CAS response property that contains further information about errors. + */ +const CAS_RETURNED_MESSAGES = "CAS-Returned-Messages"; + @Injectable() export class CASService { private cachedCASToken: CASCachedAuthDetails; @@ -128,8 +133,8 @@ export class CASService { const config = await this.getAuthConfig(); const submittedData: CreateSupplierAndSiteSubmittedData = { SupplierName: formatUserName( - supplierData.lastName, supplierData.firstName, + supplierData.lastName, ), SubCategory: "Individual", Sin: supplierData.sin, @@ -159,6 +164,18 @@ export class CASService { }, }; } catch (error: unknown) { + if ( + error instanceof AxiosError && + error.response?.status === HttpStatus.BAD_REQUEST && + !!error.response?.data[CAS_RETURNED_MESSAGES] + ) { + // Checking for bad request errors for better logging while the + // specific ticket to handle exception is pending. + throw new CustomNamedError( + error.response.data[CAS_RETURNED_MESSAGES], + CAS_BAD_REQUEST, + ); + } throw new Error("Error while creating supplier and site on CAS.", { cause: error, }); diff --git a/sources/packages/backend/libs/integrations/src/constants/error-code.constants.ts b/sources/packages/backend/libs/integrations/src/constants/error-code.constants.ts index 716c70c4be..72dfad4b00 100644 --- a/sources/packages/backend/libs/integrations/src/constants/error-code.constants.ts +++ b/sources/packages/backend/libs/integrations/src/constants/error-code.constants.ts @@ -1,4 +1,10 @@ export const DOCUMENT_NUMBER_NOT_FOUND = "DOCUMENT_NUMBER_NOT_FOUND"; -// Error code used when there is an CAS authentication error. +/** + * Error code used when there is an CAS authentication error. + */ export const CAS_AUTH_ERROR = "CAS_AUTH_ERROR"; +/** + * CAS bad request error. + */ +export const CAS_BAD_REQUEST = "CAS_BAD_REQUEST";