Skip to content

Commit

Permalink
feat: #686 External API for FSP Tracker (#688)
Browse files Browse the repository at this point in the history
* Add fsp-tracker module and service code.

* Remove unnecessary code.

* Use PositiveIntPipe

* Minor rename folders/files.

* Minor method argument adjustment.

* Refactoring.

* Add tests

* Fix data return type. Minor variable renamed.

---------

Co-authored-by: Derek Roberts <[email protected]>
  • Loading branch information
ianliuwk1019 and DerekRoberts authored Sep 6, 2024
1 parent 571d1ed commit c35e2c4
Show file tree
Hide file tree
Showing 8 changed files with 292 additions and 5 deletions.
12 changes: 7 additions & 5 deletions api/src/app/app.module.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,24 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ScheduleModule } from '@nestjs/schedule';
import { TypeOrmModule } from '@nestjs/typeorm';

// Core Modules
import { AttachmentModule } from './modules/attachment/attachment.module';
import { InteractionModule } from './modules/interaction/interaction.module';
import { DistrictModule } from './modules/district/district.module';
import { ForestClientModule } from './modules/forest-client/forest-client.module';
import { ProjectModule } from './modules/project/project.module';
import { InteractionModule } from './modules/interaction/interaction.module';
import { ProjectAuthModule } from './modules/project/project-auth.module';
import { ProjectModule } from './modules/project/project.module';
import { PublicCommentModule } from './modules/public-comment/public-comment.module';
import { SubmissionModule } from './modules/submission/submission.module';
import { SpatialFeatureModule } from './modules/spatial-feature/spatial-feature.module';
import { SubmissionModule } from './modules/submission/submission.module';

// Other Modules
import { ExternalModule } from '@src/app/modules/external/external.module';
import { LoggerModule } from 'nestjs-pino';
import { SecurityModule } from '../core/security/security.module';
import { AppConfigModule } from './modules/app-config/app-config.module';
import { AppConfigService } from './modules/app-config/app-config.provider';
import { SecurityModule } from '../core/security/security.module'

function getLogLevel():string {
return process.env.LOG_LEVEL || 'info';
Expand Down Expand Up @@ -66,6 +67,7 @@ function getLogLevel():string {
PublicCommentModule,
SubmissionModule,
SpatialFeatureModule,
ExternalModule
],
})

Expand Down
13 changes: 13 additions & 0 deletions api/src/app/modules/external/external.module.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ProjectsByFspExternalModule } from '@src/app/modules/external/projects-by-fsp/projects-by-fsp.module';

/**
* "External" module provides external facing APIs for other system to
* interface with FOM.
*/
@Module({
imports: [
ProjectsByFspExternalModule
]
})
export class ExternalModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
import { ArgumentMetadata, BadRequestException, Controller, Get, HttpStatus, Injectable, PipeTransform, Query } from '@nestjs/common';
import { ApiQuery, ApiResponse, ApiTags } from '@nestjs/swagger';
import { ProjectByFspResponse as ProjectsByFspResponse } from '@src/app/modules/external/projects-by-fsp/projects-by-fsp.dto';
import { ProjectsByFspService } from '@src/app/modules/external/projects-by-fsp/projects-by-fsp.service';
import { PinoLogger } from 'nestjs-pino';

// Custom pipe transformer for controller parameter conversion.
@Injectable()
export class PositiveIntPipe implements PipeTransform<number, number> {
transform(value: any, metadata: ArgumentMetadata) {

if(/^\d+$/.test(value)) {
const intValue = parseInt(value);
if ( intValue > 0) {
return value;
}
}

throw new BadRequestException('Value must be positive integer.');
}
}

@ApiTags("external")
@Controller("external")
export class ProjectsByFspController {
constructor(
private readonly service: ProjectsByFspService,
private readonly _logger: PinoLogger) {
}

@Get("fom-by-fsp")
@ApiQuery({ name: 'fspId', required: true})
@ApiResponse({ status: HttpStatus.OK, type: [ProjectsByFspResponse] })
async findByFsp(
@Query('fspId', PositiveIntPipe) fspId: number
): Promise<ProjectsByFspResponse[]> {
return this.service.findByFspId(fspId);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { ApiProperty, ApiPropertyOptional } from "@nestjs/swagger";
import { ForestClientResponse } from "@src/app/modules/forest-client/forest-client.dto";

export class ProjectByFspResponse {

@ApiProperty()
fomId: number;

@ApiProperty()
name: string;

@ApiProperty()
fspId: number;

@ApiPropertyOptional()
forestClient: ForestClientResponse;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { Module } from '@nestjs/common';
import { TypeOrmModule } from '@nestjs/typeorm';
import { ProjectsByFspController } from '@src/app/modules/external/projects-by-fsp/projects-by-fsp.controller';
import { ProjectsByFspService } from '@src/app/modules/external/projects-by-fsp/projects-by-fsp.service';
import { ForestClientModule } from '@src/app/modules/forest-client/forest-client.module';
import { Project } from '@src/app/modules/project/project.entity';
import { ProjectModule } from '@src/app/modules/project/project.module';

@Module({
imports: [
TypeOrmModule.forFeature([Project]),
ProjectModule,
ForestClientModule
],
controllers: [ProjectsByFspController],
providers: [ProjectsByFspService]
})
export class ProjectsByFspExternalModule {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { Test, TestingModule } from "@nestjs/testing";
import { getRepositoryToken } from "@nestjs/typeorm";
import { ProjectByFspResponse } from "@src/app/modules/external/projects-by-fsp/projects-by-fsp.dto";
import { ProjectsByFspService } from "@src/app/modules/external/projects-by-fsp/projects-by-fsp.service";
import { ForestClientService } from "@src/app/modules/forest-client/forest-client.service";
import { Project } from "@src/app/modules/project/project.entity";
import { RecursivePartial } from "@src/core/utils";
import { PinoLogger } from "nestjs-pino";
import { DataSource, Repository } from "typeorm";

describe('ProjectsByFspService', () => {
let service: ProjectsByFspService;
let forestClientService: ForestClientService;
let repository: Repository<Project>;
let createQueryBuilderMock: any;

// service and dependencies setup.
beforeAll(async () => {
const moduleRef: TestingModule = await Test.createTestingModule({
providers: provideDependencyMock()
}).compile();

forestClientService = moduleRef.get<ForestClientService>(ForestClientService);
service = moduleRef.get<ProjectsByFspService>(ProjectsByFspService);
repository = moduleRef.get(getRepositoryToken(Project));

createQueryBuilderMock = {
leftJoinAndSelect: () => createQueryBuilderMock,
where: () => createQueryBuilderMock,
andWhere: () => createQueryBuilderMock,
orWhere: () => createQueryBuilderMock,
addOrderBy: () => createQueryBuilderMock,
limit: () => createQueryBuilderMock,
getMany: () => [], // default empty
};

});

// This is prerequisite to make sure services/repository are setup for testing.
it('Services/repository for testing should be defined', () => {
expect(service).toBeDefined();
expect(forestClientService).toBeDefined();
expect(repository).toBeDefined();
});

describe('findByFspId', () => {
it('returns empty array when no fspId param', async () => {
expect(await service.findByFspId(null)).toEqual([]);
});

it('returns empty array when query builder result is empty.', async () => {
// use default createQueryBuilderMock
const createQueryBuilderSpy = jest.spyOn(repository, 'createQueryBuilder').mockImplementation(() => createQueryBuilderMock);
const fspIdWithNoFom = 999;
expect(await service.findByFspId(fspIdWithNoFom)).toEqual([]);
expect(createQueryBuilderSpy).toHaveBeenCalled();
});

it('returns correct result when query builder finds records.', async () => {
const foundProjects = getSimpleProjectResponseData();
createQueryBuilderMock.getMany = jest.fn().mockReturnValue(foundProjects);
const createQueryBuilderSpy = jest.spyOn(repository, 'createQueryBuilder').mockImplementation(() => createQueryBuilderMock);
const forestClientServiceConvertEntitySpy = jest.spyOn(forestClientService, 'convertEntity');
const fspIdWithFom = 11;
const result = await service.findByFspId(fspIdWithFom);
expect(result.length).toEqual(getSimpleProjectResponseData().length);
expect(result[0]).toBeInstanceOf(ProjectByFspResponse)
expect(createQueryBuilderSpy).toHaveBeenCalled();
expect(forestClientServiceConvertEntitySpy).toHaveBeenCalled();
expect(result[0].fspId).toEqual(foundProjects[0].fspId)
});
});

});

export class ProjectRepositoryFake {
public createQueryBuilder(): void {
// This is intentional for empty body.
}
}

function provideDependencyMock(): Array<any> {
const dependencyMock =
[ ProjectsByFspService,
{
provide: getRepositoryToken(Project),
useClass: ProjectRepositoryFake
},
{
provide: PinoLogger,
useValue: {
info: jest.fn((x) => x),
debug: jest.fn((x) => x),
setContext: jest.fn((x) => x),
}
},
{
provide: ForestClientService,
useValue: {
convertEntity: jest.fn()
}
},
{
provide: DataSource,
useValue: {
getRepository: jest.fn()
}
}
];
return dependencyMock;
}

function getSimpleProjectResponseData(): RecursivePartial<Project>[] {
const data = [
{
"id": 1,
"name": "Project #1",
"fspId": 11,
"forestClient": {
"id": "00001012",
"name": "CLIENT #1"
}
},
{
"id": 2,
"name": "Project #2",
"fspId": 11,
"forestClient": {
"id": "00001015",
"name": "CLIENT #2"
}
}
]
return data;
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { ProjectByFspResponse } from "@api-modules/external/projects-by-fsp/projects-by-fsp.dto";
import { Project } from "@api-modules/project/project.entity";
import { DataService } from "@core";
import { Injectable } from "@nestjs/common";
import { InjectRepository } from "@nestjs/typeorm";
import { ForestClientService } from "@src/app/modules/forest-client/forest-client.service";
import { ProjectFindCriteria } from "@src/app/modules/project/project.service";
import { PinoLogger } from "nestjs-pino";
import { Repository } from "typeorm";
import _ = require('lodash');

@Injectable()
export class ProjectsByFspService extends DataService<Project, Repository<Project>, ProjectByFspResponse> {

constructor(
@InjectRepository(Project)
repository: Repository<Project>,
private forestClientService: ForestClientService,
logger: PinoLogger
) {
super(repository, new Project(), logger);
}

async findByFspId(fspId: number):Promise<ProjectByFspResponse[]> {
if (_.isNil(fspId)) {
return []
}
const findCriteria: ProjectFindCriteria = new ProjectFindCriteria();
findCriteria.fspId = fspId;
this.logger.debug('Find criteria: %o', findCriteria);

const query = this.repository.createQueryBuilder("p")
.leftJoinAndSelect("p.forestClient", "forestClient")
.addOrderBy('p.project_id', 'DESC');
findCriteria.applyFindCriteria(query);
query.limit(2500); // Can't use take(). Limit # of results to avoid system strain.

const queryResults:Project[] = await query.getMany();
if (queryResults && queryResults.length > 0) {
this.logger.debug(`${queryResults.length} project(s) found.`);
return queryResults.map(project => this.convertEntity(project));
}
this.logger.debug('No result found.');
return [];
}

convertEntity(entity: Project): ProjectByFspResponse {
const response = new ProjectByFspResponse();
response.fomId = entity.id
response.name = entity.name
response.fspId = entity.fspId;
if (entity.forestClient != null) {
response.forestClient = this.forestClientService.convertEntity(entity.forestClient);
}
return response;
}

}
5 changes: 5 additions & 0 deletions api/src/core/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,3 +66,8 @@ export const flatDeep = (arr, d = 1) => {
return d > 0 ? arr.reduce((acc, val) => acc.concat(Array.isArray(val) ? flatDeep(val, d - 1) : val), [])
: arr.slice();
};

// Partial type with nested properties also as Partial.
export type RecursivePartial<T> = {
[P in keyof T]?: RecursivePartial<T[P]>;
};

0 comments on commit c35e2c4

Please sign in to comment.