diff --git a/api/jest.config.js b/api/jest.config.js index 1648a89fd..885c6e9fd 100644 --- a/api/jest.config.js +++ b/api/jest.config.js @@ -27,5 +27,11 @@ module.exports = { "cobertura" ], testResultsProcessor: "jest-sonar-reporter", - testEnvironment: "node" + testEnvironment: "node", + + // configure `resetMocks` and `restoreMocks` to automatically reset/restor mock state before every test. + // Jest doc isn't quite clear about the difference. But, with 'Spy's, `restoreMocks` will + // restores their initial implementation. + resetMocks: true, + restoreMocks: true }; diff --git a/api/src/app/modules/forest-client/forest-client.service.spec.ts b/api/src/app/modules/forest-client/forest-client.service.spec.ts new file mode 100644 index 000000000..efc7eef20 --- /dev/null +++ b/api/src/app/modules/forest-client/forest-client.service.spec.ts @@ -0,0 +1,175 @@ +import { ClientAppIntegrationResponse } from '@api-core/client-app-integration/client-app-integration.dto'; +import { ClientAppIntegrationService } from '@api-core/client-app-integration/client-app-integration.service'; +import { ForestClient } from '@api-modules/forest-client/forest-client.entity'; +import { ForestClientService } from '@api-modules/forest-client/forest-client.service'; +import { Test, TestingModule } from '@nestjs/testing'; +import { getRepositoryToken } from '@nestjs/typeorm'; +import { AppConfigService } from '@src/app/modules/app-config/app-config.provider'; +import { PinoLogger } from 'nestjs-pino'; +import { DataSource, FindOneOptions, Repository } from 'typeorm'; + +describe('ForestClientService', () => { + let service: ForestClientService; + let clientAppIntegrationService: ClientAppIntegrationService; + let repository: Repository; + let configService: AppConfigService; + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: provideDependencyMock() + }).compile(); + + service = module.get(ForestClientService); + clientAppIntegrationService = module.get(ClientAppIntegrationService); + repository = module.get(getRepositoryToken(ForestClient)); + configService = module.get(AppConfigService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + describe('batchClientDataRefresh', () => { + let repositoryFindOneSpy; + let repositoryUpsertSpy; + let fetchClientNonIndividualsSpy; + let configServiceGetPageSizeSpy; + const PAGE_SIZE_DEFAULT = 1000; + beforeEach(async () => { + repositoryFindOneSpy = jest.spyOn(repository, 'findOne'); + repositoryUpsertSpy = jest.spyOn(repository, 'upsert'); + fetchClientNonIndividualsSpy = jest.spyOn(clientAppIntegrationService, 'fetchClientNonIndividuals'); + configServiceGetPageSizeSpy = jest.spyOn(configService, 'get'); + }); + + it('Should end data refreshing when no data fetched from Client API.', async () => { + fetchClientNonIndividualsSpy.mockResolvedValue([] as ClientAppIntegrationResponse[]); + const called_with_first_page = 0; + configServiceGetPageSizeSpy.mockReturnValue(PAGE_SIZE_DEFAULT) + await service.batchClientDataRefresh(); + expect(fetchClientNonIndividualsSpy).toHaveBeenCalled(); + // Expect function fetched the first page. + expect(fetchClientNonIndividualsSpy).toHaveBeenCalledWith( + called_with_first_page, + PAGE_SIZE_DEFAULT, + ClientAppIntegrationService.SORT_BY_CLIENT_NUMBER + ); + // Expect function did not fetch the second page. + expect(fetchClientNonIndividualsSpy).not.toHaveBeenCalledWith( + called_with_first_page + 1, + PAGE_SIZE_DEFAULT, + ClientAppIntegrationService.SORT_BY_CLIENT_NUMBER + ); + }); + + it('Should do data refresh when data returns and end data refreshing when subsequent call having no data fetched from Client API.', async () => { + // Make fetching calls for max length times. + const stopAtPage = sampleClientAppIntegrationResponseList.length; // Note, API starts at "page" 0. + fetchClientNonIndividualsSpy.mockImplementation( + (page, pageSize, sortedColumn) => mockFetchClientNonIndividualsStopAtPage(stopAtPage, page, pageSize, sortedColumn) + ); + configServiceGetPageSizeSpy.mockReturnValue(PAGE_SIZE_DEFAULT) + await service.batchClientDataRefresh(); + expect(fetchClientNonIndividualsSpy).toHaveBeenCalled(); + expect(fetchClientNonIndividualsSpy).toBeCalledTimes(stopAtPage + 1); + expect(repositoryUpsertSpy).toHaveBeenCalled(); + expect(repositoryUpsertSpy).toBeCalledTimes(1*stopAtPage); + }); + + }); + +}); + +// Mock function to simulate calling API fetching data with page in sequence. +// Note API "page" param starts at 0; +// To simplify, returns only 1 item in list. +// Can only be called with stopAtPage <= sampleClientAppIntegrationResponseList.length +async function mockFetchClientNonIndividualsStopAtPage( + stopAtPage: number, + page: number, + _pageSize, + _sortedColumn): Promise +{ + if (page >= 0 && page < stopAtPage && stopAtPage <= sampleClientAppIntegrationResponseList.length) { + return [sampleClientAppIntegrationResponseList[page]]; + } + return []; // empty list to indicates API return empty result for page. +} + +class ForestClientRepositoryFake { + public findOne(options: FindOneOptions): Promise { + return null; + } + public upsert(): void { + // This is intentional for empty body. + } +} + +function provideDependencyMock(): Array { + const dependencyMock = [ + ForestClientService, + { + provide: getRepositoryToken(ForestClient), + useClass: ForestClientRepositoryFake + }, + { + provide: DataSource, + useValue: { + getRepository: jest.fn() + } + }, + { + provide: ClientAppIntegrationService, + useValue: { + fetchClientNonIndividuals: jest.fn() + } + }, + { + provide: AppConfigService, + useValue: { + get: jest.fn((x) => x) + } + }, + { + provide: PinoLogger, + useValue: { + info: jest.fn((x) => x), + setContext: jest.fn((x) => x), + } + } + ] + return dependencyMock; +} + +const sampleClientAppIntegrationResponseList = [ + { + id: '00000001', + name: 'MANAGEMENT ABEYANCE', + clientStatusCode: 'ACT', + clientTypeCode: 'C' + }, + { + id: '00000002', + name: 'PENDING S & R BILLING', + clientStatusCode: 'DAC', + clientTypeCode: 'G' + }, + { + id: '00000003', + name: 'SECURITY & DAMAGE DEPOSITS', + clientStatusCode: 'DAC', + clientTypeCode: 'G' + }, + { + id: '00000004', + name: 'EXPORT DEPOSITS', + clientStatusCode: 'ACT', + clientTypeCode: 'C' + }, + { + id: '00000005', + name: 'CHRISTMAS TREE DEPOSITS', + clientStatusCode: 'DAC', + clientTypeCode: 'C' + } +] as ClientAppIntegrationResponse[]; + diff --git a/api/src/app/modules/project/project.service.spec.ts b/api/src/app/modules/project/project.service.spec.ts index 591724d21..32e91f09a 100644 --- a/api/src/app/modules/project/project.service.spec.ts +++ b/api/src/app/modules/project/project.service.spec.ts @@ -190,12 +190,6 @@ describe('ProjectService', () => { const publicNoticeWithNoPostDate = new PublicNotice() entity.publicNotices = [publicNoticeWithNoPostDate]; }); - - afterEach(() => { - // restoreAllmocks() will reset mocks and restore to original implementataion - // and in beforeEach, we set the spy again - jest.restoreAllMocks(); - }); it('with no public-notice pass', async () => { entity.commentingOpenDate = dayjs().add(1, 'day').format(DateTimeUtil.DATE_FORMAT); diff --git a/api/src/core/client-app-integration/client-app-integration.service.spec.ts b/api/src/core/client-app-integration/client-app-integration.service.spec.ts index 46401445f..3cfe7104e 100644 --- a/api/src/core/client-app-integration/client-app-integration.service.spec.ts +++ b/api/src/core/client-app-integration/client-app-integration.service.spec.ts @@ -1,14 +1,15 @@ import { HttpService } from '@nestjs/axios'; import { Test, TestingModule } from '@nestjs/testing'; import { ClientAppIntegrationResponse } from '@src/core/client-app-integration/client-app-integration.dto'; -import { AxiosResponse } from 'axios'; +import { AxiosError, AxiosResponse } from 'axios'; import { PinoLogger } from 'nestjs-pino'; import { of } from 'rxjs'; import { ClientAppIntegrationService } from './client-app-integration.service'; describe('ClientAppIntegrationService', () => { + const CLIENT_FETCH_ALL_API_PATH = "/findAllNonIndividuals"; let service: ClientAppIntegrationService; - let httpService; HttpService; + let httpService: HttpService; beforeEach(async () => { const module: TestingModule = await Test.createTestingModule({ @@ -24,47 +25,125 @@ describe('ClientAppIntegrationService', () => { }); describe('fetchClientNonIndividuals', () => { - const sampleParams = {"page": 1, "size": 1000, "sortedColumnName": "clientNumber"}; + // page starts from '0' as first page. + const sampleParams = {"page": 0, "size": 1000, "sortedColumnName": "clientNumber"}; + let httpGetSpy: any; beforeEach(async () => { - jest.resetAllMocks(); + httpGetSpy = jest.spyOn(httpService, 'get'); }); it('Should call http service with mapped return.', async () => { - const httpGetSpy = jest.spyOn(httpService, 'get'); httpGetSpy.mockReturnValue(of(sampleAxiosResponse)); - const expectMappedResult = sampleAxiosResponse.data.map((item: any)=> { - const mapped = new ClientAppIntegrationResponse(); - mapped.id = item.clientNumber; - mapped.name = item.clientName; - mapped.clientStatusCode = item.clientStatusCode; - mapped.clientTypeCode = item.clientTypeCode; - return mapped; - }) + + const expectMappedResult = getExpectMappedResult(sampleAxiosResponse); const result = await service.fetchClientNonIndividuals(sampleParams.page, sampleParams.size, sampleParams.sortedColumnName); expect(httpGetSpy).toHaveBeenCalled(); - expect(httpGetSpy).toHaveBeenCalledWith(expect.stringContaining("/findAllNonIndividuals"), { params: sampleParams }); + expect(httpGetSpy).toHaveBeenCalledWith(expect.stringContaining(CLIENT_FETCH_ALL_API_PATH), { params: sampleParams }); expect(expectMappedResult).toMatchObject(result); + expect(expectMappedResult[0].id).toBe(sampleAxiosResponse.data[0].clientNumber); + expect(expectMappedResult[0].name).toBe(sampleAxiosResponse.data[0].clientName); + expect(expectMappedResult[1].id).toBe(sampleAxiosResponse.data[1].clientNumber); + expect(expectMappedResult[1].name).toBe(sampleAxiosResponse.data[1].clientName); }) it('Should return empty when CLIENT api returns empty data.', async () => { - const httpGetSpy = jest.spyOn(httpService, 'get'); httpGetSpy.mockReturnValue(of(Object.assign({}, {...sampleAxiosResponse}, {data: []}))) + const result = await service.fetchClientNonIndividuals(sampleParams.page, sampleParams.size, sampleParams.sortedColumnName); expect(httpGetSpy).toHaveBeenCalled(); + expect(httpGetSpy).toHaveBeenCalledWith(expect.stringContaining(CLIENT_FETCH_ALL_API_PATH), { params: sampleParams }); expect([]).toMatchObject(result); + expect(result.length).toBe(0); }); it('Should return empty when CLIENT api returns undefined data.', async () => { - const httpGetSpy = jest.spyOn(httpService, 'get'); httpGetSpy.mockReturnValue(of(Object.assign({}, {...sampleAxiosResponse}, {data: undefined}))) + const result = await service.fetchClientNonIndividuals(sampleParams.page, sampleParams.size, sampleParams.sortedColumnName); expect(httpGetSpy).toHaveBeenCalled(); + expect(httpGetSpy).toHaveBeenCalledWith(expect.stringContaining(CLIENT_FETCH_ALL_API_PATH), { params: sampleParams }); expect([]).toMatchObject(result); + expect(result.length).toBe(0); + }); + + it('Should return correct mapped data for subsequent calls to api', async () => { + const callParams_1 = sampleParams; + const callParams_2 = {...sampleParams, "page": callParams_1.page + 1}; + // custom http mocked implementation for this case. + httpGetSpy.mockImplementation((_url, config) => { + const params = config.params; + if (params["page"] == callParams_1.page) { // page 0 + return of(sampleAxiosResponse); + } + else if (params["page"] == callParams_2.page) { // page 1 + return of(page2SampleAxiosResponse); + } + else { // page 2, stops. + return of({...sampleAxiosResponse, data: []}); + } + }) + + // First page request. + let page = callParams_1.page; + const expectP1MappedResult = getExpectMappedResult(sampleAxiosResponse); + const result1 = await service.fetchClientNonIndividuals( + page, callParams_1.size, callParams_1.sortedColumnName + ); + expect(httpGetSpy).toHaveBeenCalledTimes(page + 1); // page starts as 0 + expect(httpGetSpy).toHaveBeenCalledWith(expect.stringContaining(CLIENT_FETCH_ALL_API_PATH), { params: callParams_1 }); + expect(expectP1MappedResult).toMatchObject(result1); + expect(expectP1MappedResult[0].id).toBe(sampleAxiosResponse.data[0].clientNumber); + expect(expectP1MappedResult[0].name).toBe(sampleAxiosResponse.data[0].clientName); + + // Second page request. + page = callParams_2.page; + const expectP2MappedResult = getExpectMappedResult(page2SampleAxiosResponse); + const result2 = await service.fetchClientNonIndividuals( + page, callParams_2.size, callParams_2.sortedColumnName + ); + expect(httpGetSpy).toHaveBeenCalledTimes(page + 1); // page starts as 0 + expect(httpGetSpy).toHaveBeenCalledWith(expect.stringContaining(CLIENT_FETCH_ALL_API_PATH), { params: callParams_2 }); + expect(expectP2MappedResult).toMatchObject(result2); + expect(expectP2MappedResult[0].id).toBe(page2SampleAxiosResponse.data[0].clientNumber); + expect(expectP2MappedResult[0].name).toBe(page2SampleAxiosResponse.data[0].clientName); + + // Third page request should ends. + page = callParams_2.page + 1; + const callParams_3 = {...callParams_2, "page": page}; + const expectP3MappedResult = getExpectMappedResult({...sampleAxiosResponse, data: []}); // empty data returns; + const result3 = await service.fetchClientNonIndividuals( + page, callParams_3.size, callParams_3.sortedColumnName + ); + expect(httpGetSpy).toHaveBeenCalledTimes(page + 1); // page starts as 0 + expect(httpGetSpy).toHaveBeenCalledWith(expect.stringContaining(CLIENT_FETCH_ALL_API_PATH), { params: callParams_3 }); + expect(expectP3MappedResult.length).toBe(0); + expect(result3.length).toBe(0); + }); + + it('Should throw error when api returns error.', async () => { + const failed403Error = new AxiosError( + 'You cannot consume this service', // CLIENT message for 403. + '403' + ); + httpGetSpy.mockImplementation((_url, _config) => { + throw failed403Error; + }); + + const resultExpect = await expect( + () => service.fetchClientNonIndividuals( + sampleParams.page, sampleParams.size, sampleParams.sortedColumnName + ) + ) + + resultExpect.rejects.toBeInstanceOf(AxiosError); + resultExpect.rejects.toThrow(failed403Error); }); }); }); +// --- Dependencies mock setup. + function provideDependencyMock(): Array { const dependencyMock = [ { @@ -85,6 +164,19 @@ function provideDependencyMock(): Array { return dependencyMock; } +// --- Helper functions and objects. + +function getExpectMappedResult(response: AxiosResponse): ClientAppIntegrationResponse[] { + return response.data.map((item: any)=> { + const mapped = new ClientAppIntegrationResponse(); + mapped.id = item.clientNumber; + mapped.name = item.clientName; + mapped.clientStatusCode = item.clientStatusCode; + mapped.clientTypeCode = item.clientTypeCode; + return mapped; + }); +} + const sampleAxiosResponse: AxiosResponse = { data: [ { @@ -107,3 +199,18 @@ const sampleAxiosResponse: AxiosResponse = { headers: {}, }; +const page2SampleAxiosResponse: AxiosResponse = {...sampleAxiosResponse, data: [ + { + clientNumber: '00001001', + clientName: 'MANAGEMENT ABEYANCE 2', + clientStatusCode: 'DAC', + clientTypeCode: 'G' + }, + { + clientNumber: '00001002', + clientName: 'PENDING S & R BILLING 2', + clientStatusCode: 'ACT', + clientTypeCode: 'C' + } +]}; +