Skip to content

Commit

Permalink
Chores/client data refresh additional tests (#552)
Browse files Browse the repository at this point in the history
* Tmp new service.

* Add @nestjs/axios package.

* Add external forest client app configuration.

* Create client-app module and service with configuration.

* Make fetchClientNonIndividuals works to call eternal with httpService.

* Add insert/update for data refreshing

* Minor comment.

* Remove ForestClientController from extending BaseReadOnlyController

* Rename previous batch job name for workFlowStateChange, and add new one for forestClientDataRefresh.

* Export new constant to use.

* Add data refresh cron job for openshift template. Rename previous batch for more specific.

* Remove comment.

* Fix environment naming mismatch.

* Add missing deployment env CLIENT_API_BASE_URL

* Add tsconfig for src path to be easier to use.

* Add client-app-integration.service tests.

* Fix typo.

* Fix typo

* Add new temporay test file "forest-client.service.spec.ts", wip.

* Add Jest configuration for mock.

* Add more client-app-integration.service tests.

* Add test when error throw.

* Fix package-lock merging duplication.

* Remove resetAllMocks and restoreAllMocks as they are configured in Jest setting now.

* Add some comment for configuration.
  • Loading branch information
ianliuwk1019 authored Jan 8, 2024
1 parent 98c9a18 commit 0a447a7
Show file tree
Hide file tree
Showing 4 changed files with 305 additions and 23 deletions.
8 changes: 7 additions & 1 deletion api/jest.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -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
};
175 changes: 175 additions & 0 deletions api/src/app/modules/forest-client/forest-client.service.spec.ts
Original file line number Diff line number Diff line change
@@ -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<ForestClient>;
let configService: AppConfigService;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: provideDependencyMock()
}).compile();

service = module.get<ForestClientService>(ForestClientService);
clientAppIntegrationService = module.get<ClientAppIntegrationService>(ClientAppIntegrationService);
repository = module.get(getRepositoryToken(ForestClient));
configService = module.get<AppConfigService>(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<ClientAppIntegrationResponse[]>
{
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<ForestClient>): Promise<ForestClient | null> {
return null;
}
public upsert(): void {
// This is intentional for empty body.
}
}

function provideDependencyMock(): Array<any> {
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[];

6 changes: 0 additions & 6 deletions api/src/app/modules/project/project.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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({
Expand All @@ -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<AxiosResponse>(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<ClientAppIntegrationResponse[]>(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<AxiosResponse>(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<ClientAppIntegrationResponse[]>(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<AxiosResponse>(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<ClientAppIntegrationResponse[]>(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<AxiosResponse>(sampleAxiosResponse);
}
else if (params["page"] == callParams_2.page) { // page 1
return of<AxiosResponse>(page2SampleAxiosResponse);
}
else { // page 2, stops.
return of<AxiosResponse>({...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<ClientAppIntegrationResponse[]>(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<ClientAppIntegrationResponse[]>(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<any> {
const dependencyMock = [
{
Expand All @@ -85,6 +164,19 @@ function provideDependencyMock(): Array<any> {
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: [
{
Expand All @@ -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'
}
]};

0 comments on commit 0a447a7

Please sign in to comment.