Skip to content

[TM-2121] add controller disturbance associations multiple entities #208

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

Open
wants to merge 6 commits into
base: staging
Choose a base branch
from
Open
6 changes: 5 additions & 1 deletion apps/entity-service/src/app.module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import { DemographicsController } from "./entities/demographics.controller";
import { DemographicService } from "./entities/demographic.service";
import { ImpactStoriesController } from "./entities/impact-stories.controller";
import { ImpactStoryService } from "./entities/impact-story.service";
import { DisturbancesController } from "./entities/disturbances.controller";
import { DisturbanceService } from "./entities/disturbance.service";

@Module({
imports: [SentryModule.forRoot(), CommonModule, HealthModule, DataApiModule],
Expand All @@ -35,6 +37,7 @@ import { ImpactStoryService } from "./entities/impact-story.service";
TreesController,
BoundingBoxController,
DemographicsController,
DisturbancesController,
EntitiesController,
EntityAssociationsController
],
Expand All @@ -50,7 +53,8 @@ import { ImpactStoryService } from "./entities/impact-story.service";
ImpactStoryService,
BoundingBoxService,
TasksService,
DemographicService
DemographicService,
DisturbanceService
]
})
export class AppModule {}
90 changes: 90 additions & 0 deletions apps/entity-service/src/entities/disturbance.controller.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { Disturbance, SiteReport } from "@terramatch-microservices/database/entities";
import { createMock, DeepMocked } from "@golevelup/ts-jest";
import { Test } from "@nestjs/testing";
import { BadRequestException } from "@nestjs/common";
import { PolicyService } from "@terramatch-microservices/common";
import { ProjectPitchQueryDto } from "./dto/project-pitch-query.dto";
import { SiteReportFactory } from "@terramatch-microservices/database/factories";
import { DisturbancesController } from "./disturbances.controller";
import { DisturbanceService } from "./disturbance.service";
import { DisturbanceQueryDto } from "./dto/disturbance-query.dto";

describe("DisturbanceController", () => {
let controller: DisturbancesController;
let disturbanceService: DeepMocked<DisturbanceService>;
let policyService: DeepMocked<PolicyService>;

beforeEach(async () => {
const module = await Test.createTestingModule({
controllers: [DisturbancesController],
providers: [
{ provide: DisturbanceService, useValue: (disturbanceService = createMock<DisturbanceService>()) },
{ provide: PolicyService, useValue: (policyService = createMock<PolicyService>()) }
]
}).compile();

controller = module.get(DisturbancesController);
});

afterEach(() => {
jest.restoreAllMocks();
});

describe("Disturbance Index", () => {
it("should return disturbances successfully", async () => {
const mockResponse = {
data: [],
paginationTotal: 0,
pageNumber: 1
};
policyService.getPermissions.mockResolvedValue(["framework-ppc"]);

disturbanceService.getDisturbances.mockResolvedValue(mockResponse);

const result = await controller.disturbancesIndex(new DisturbanceQueryDto());
expect(disturbanceService.getDisturbances).toHaveBeenCalledTimes(1);
expect(result).toBeDefined();
});

it("should return an array of 3 disturbance successfully", async () => {
const siteReport = await SiteReportFactory.create();
const mockResponse = {
data: [
new Disturbance({
uuid: "1",
type: "type 1",
disturbanceableType: SiteReport.LARAVEL_TYPE,
disturbanceableId: siteReport.id
} as Disturbance),
new Disturbance({
uuid: "2",
type: "type 2",
disturbanceableType: SiteReport.LARAVEL_TYPE,
disturbanceableId: siteReport.id
} as Disturbance)
],
paginationTotal: 3,
pageNumber: 1
};
policyService.getPermissions.mockResolvedValue(["framework-ppc"]);
disturbanceService.getDisturbances.mockResolvedValue(mockResponse);

const result = await controller.disturbancesIndex(new DisturbanceQueryDto());
expect(result).toBeDefined();
expect(Array.isArray(result.data) ? result.data.length : 0).toBe(2);
expect(disturbanceService.getDisturbances).toHaveBeenCalledTimes(1);
});

it("should throw BadRequestException for invalid parameters", async () => {
disturbanceService.getDisturbances.mockRejectedValue(new BadRequestException("Invalid parameters"));

await expect(controller.disturbancesIndex(new ProjectPitchQueryDto())).rejects.toThrow(BadRequestException);
});

it("should handle unexpected errors gracefully", async () => {
disturbanceService.getDisturbances.mockRejectedValue(new Error("Unexpected error"));

await expect(controller.disturbancesIndex(new ProjectPitchQueryDto())).rejects.toThrow(Error);
});
});
});
103 changes: 103 additions & 0 deletions apps/entity-service/src/entities/disturbance.service.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
import { Test } from "@nestjs/testing";
import { Disturbance, SiteReport } from "@terramatch-microservices/database/entities";
import { NumberPage } from "@terramatch-microservices/common/dto/page.dto";
import { DisturbanceService } from "./disturbance.service";
import { DisturbanceQueryDto } from "./dto/disturbance-query.dto";

function getDefaultPagination() {
const params = new DisturbanceQueryDto();
params.page = new NumberPage();
params.page.number = 1;
params.page.size = 10;
return params;
}

describe("DisturbanceService", () => {
let service: DisturbanceService;

beforeEach(async () => {
const module = await Test.createTestingModule({
providers: [DisturbanceService]
}).compile();

service = module.get(DisturbanceService);
});

afterEach(() => {
jest.restoreAllMocks();
});

describe("Get Disturbances", () => {
it("returns paginated disturbances", async () => {
const disturbances = [
new Disturbance({ uuid: "uuid y", type: "type 1" } as Disturbance),
new Disturbance({ uuid: "uuid x", type: "type 2" } as Disturbance)
];
jest.spyOn(Disturbance, "findAll").mockImplementation(() => Promise.resolve(disturbances));

const params = getDefaultPagination();

const result = await service.getDisturbances(params);

expect(result.data).toHaveLength(2);
expect(result.data[0].uuid).toBe("uuid y");
expect(result.data[1].uuid).toBe("uuid x");
expect(result.pageNumber).toBe(1);
});

it("deny unexpected filter", async () => {
const disturbances = [new Disturbance({ uuid: "uuid10", type: "Filtered" } as Disturbance)];
jest.spyOn(Disturbance, "findAll").mockImplementation(() => Promise.resolve(disturbances));

const params = getDefaultPagination();
params["unexpectedFilter"] = ["value1", "value2"];

await expect(service.getDisturbances(params)).rejects.toThrow("Invalid filter key: unexpectedFilter");
});

it("applies siteReportUuid filter with no coincidences", async () => {
const disturbances = [new Disturbance({ uuid: "uuid44", type: "Filtered" } as Disturbance)];
jest.spyOn(Disturbance, "findAll").mockImplementation(() => Promise.resolve(disturbances));

const params = getDefaultPagination();
params.siteReportUuid = ["uuid44", "uid45"];

const result = await service.getDisturbances(params);
expect(result.data).toHaveLength(0);
});

it("applies siteReportUuid filter", async () => {
const disturbances = [new Disturbance({ uuid: "uuid44", type: "Filtered" } as Disturbance)];
const siteReport = new SiteReport({ uuid: "uuid44" } as SiteReport);
jest.spyOn(SiteReport, "findAll").mockImplementation(() => Promise.resolve([siteReport]));
jest.spyOn(Disturbance, "findAll").mockImplementation(() => Promise.resolve(disturbances));

const params = getDefaultPagination();
params.siteReportUuid = ["uuid44", "uid45"];

const result = await service.getDisturbances(params);
expect(result.data).toHaveLength(1);
});

it("deny orders fields", async () => {
const disturbances = [new Disturbance({ uuid: "uuid1", type: "Filtered" } as Disturbance)];
jest.spyOn(Disturbance, "findAll").mockImplementation(() => Promise.resolve(disturbances));

const params = getDefaultPagination();
params.sort = { field: "no_exist_column", direction: "ASC" };

await expect(service.getDisturbances(params)).rejects.toThrow("Invalid sort field: no_exist_column");
});

it("applies order correctly", async () => {
const disturbances = [new Disturbance({ uuid: "uuid1", type: "Filtered" } as Disturbance)];
jest.spyOn(Disturbance, "findAll").mockImplementation(() => Promise.resolve(disturbances));

const params = getDefaultPagination();
params.sort = { field: "type", direction: "ASC" };

const result = await service.getDisturbances(params);
expect(result.data).toHaveLength(1);
});
});
});
75 changes: 75 additions & 0 deletions apps/entity-service/src/entities/disturbance.service.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { BadRequestException, Injectable } from "@nestjs/common";
import { Disturbance, SiteReport } from "@terramatch-microservices/database/entities";
import { PaginatedQueryBuilder } from "@terramatch-microservices/common/util/paginated-query.builder";
import { Model, ModelStatic, Op } from "sequelize";
import { DisturbanceQueryDto } from "./dto/disturbance-query.dto";

type DisturbanceFilter<T extends Model = Model> = {
uuidKey: string;
model: ModelStatic<T>;
laravelType: string;
};

const DISTURBANCES_FILTERS: DisturbanceFilter[] = [
{
uuidKey: "siteReportUuid",
model: SiteReport,
laravelType: SiteReport.LARAVEL_TYPE
}
] as const;

const VALID_FILTER_KEYS = DISTURBANCES_FILTERS.map(({ uuidKey }) => uuidKey);

@Injectable()
export class DisturbanceService {
async getDisturbances(query: DisturbanceQueryDto) {
const builder = PaginatedQueryBuilder.forNumberPage(Disturbance, query);

Object.keys(query).forEach(key => {
if (key === "page" || key === "sort") return;
if (!VALID_FILTER_KEYS.includes(key)) {
throw new BadRequestException(`Invalid filter key: ${key}`);
}
});

for (const { uuidKey, model, laravelType } of DISTURBANCES_FILTERS) {
const uuids = query[uuidKey];
if (uuids != null && uuids.length > 0) {
const records = (await model.findAll({
attributes: ["id"],
where: { uuid: { [Op.in]: uuids } }
})) as unknown as { id: number }[];

if (records.length > 0) {
const disturbanceIds = Disturbance.idsSubquery(
records.map(record => record.id),
laravelType
);
builder.where({
id: { [Op.in]: disturbanceIds }
});
} else {
return {
data: [],
paginationTotal: 0,
pageNumber: query.page?.number ?? 1
};
}
}
}

if (query.sort?.field != null) {
if (["id", "type"].includes(query.sort.field)) {
builder.order([query.sort.field, query.sort.direction ?? "ASC"]);
} else {
throw new BadRequestException(`Invalid sort field: ${query.sort.field}`);
}
}

return {
data: await builder.execute(),
paginationTotal: await builder.paginationTotal(),
pageNumber: query.page?.number ?? 1
};
}
}
68 changes: 68 additions & 0 deletions apps/entity-service/src/entities/disturbances.controller.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import {
BadRequestException,
Controller,
Get,
InternalServerErrorException,
NotFoundException,
Query
} from "@nestjs/common";
import { buildJsonApi, getStableRequestQuery } from "@terramatch-microservices/common/util";
import { ApiOperation } from "@nestjs/swagger";
import { ExceptionResponse, JsonApiResponse } from "@terramatch-microservices/common/decorators";
import { PolicyService } from "@terramatch-microservices/common";
import { DisturbanceQueryDto } from "./dto/disturbance-query.dto";
import { DisturbanceService } from "./disturbance.service";
import { LARAVEL_MODEL_TYPES, LARAVEL_MODELS } from "@terramatch-microservices/database/constants/laravel-types";
import { DisturbanceDto } from "./dto/disturbance.dto";
import { TMLogger } from "@terramatch-microservices/common/util/tm-logger";

@Controller("entities/v3/disturbances")
export class DisturbancesController {
private logger = new TMLogger(DisturbancesController.name);

constructor(private readonly disturbanceService: DisturbanceService, private readonly policyService: PolicyService) {}

@Get()
@ApiOperation({
operationId: "disturbanceIndex",
summary: "Get disturbances."
})
@JsonApiResponse([{ data: DisturbanceDto, pagination: "number" }])
@ExceptionResponse(BadRequestException, { description: "Param types invalid" })
@ExceptionResponse(NotFoundException, { description: "Records not found" })
async disturbancesIndex(@Query() params: DisturbanceQueryDto) {
const { data, paginationTotal, pageNumber } = await this.disturbanceService.getDisturbances(params);
const document = buildJsonApi(DisturbanceDto, { pagination: "number" });
const indexIds: string[] = [];
console.log(data.length, paginationTotal, pageNumber);
if (data.length !== 0) {
await this.policyService.authorize("read", data);
for (const disturbance of data) {
indexIds.push(disturbance.uuid);
const { disturbanceableType: laravelType, disturbanceableId } = disturbance;
const model = LARAVEL_MODELS[laravelType];
if (model == null) {
this.logger.error("Unknown model type", model);
throw new InternalServerErrorException("Unexpected disturbance association type");
}
const entity = await model.findOne({ where: { id: disturbanceableId }, attributes: ["uuid"] });
if (entity == null) {
this.logger.error("Disturbance parent entity not found", { model, id: disturbanceableId });
throw new NotFoundException();
}
const entityType = LARAVEL_MODEL_TYPES[laravelType];
const additionalProps = { entityType, entityUuid: entity.uuid };
const disturbanceDto = new DisturbanceDto(disturbance, additionalProps);
document.addData(disturbance.uuid, disturbanceDto);
}
}
document.addIndexData({
resource: "disturbances",
requestPath: `/entities/v3/disturbances${getStableRequestQuery(params)}`,
ids: indexIds,
total: paginationTotal,
pageNumber: pageNumber
});
return document.serialize();
}
}
10 changes: 10 additions & 0 deletions apps/entity-service/src/entities/dto/disturbance-query.dto.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
import { IndexQueryDto } from "./index-query.dto";
import { IsArray, IsOptional } from "class-validator";
import { ApiProperty } from "@nestjs/swagger";

export class DisturbanceQueryDto extends IndexQueryDto {
@ApiProperty({ required: false, isArray: true, description: "siteReport uuid array" })
@IsOptional()
@IsArray()
siteReportUuid?: string[];
}
11 changes: 11 additions & 0 deletions libs/common/src/lib/policies/disturbance.policy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
import { Disturbance } from "@terramatch-microservices/database/entities";
import { UserPermissionsPolicy } from "./user-permissions.policy";

export class DisturbancePolicy extends UserPermissionsPolicy {
async addRules() {
if (this.frameworks.length > 0) {
this.builder.can("read", Disturbance);
return;
}
}
}
Loading
Loading